From 2a71ac42852d975facc8eff2cd2d5b16520895c9 Mon Sep 17 00:00:00 2001 From: storytellerF <34095089+storytellerF@users.noreply.github.com> Date: Sun, 17 Nov 2024 19:10:47 +0800 Subject: [PATCH] fix: add search topics --- .../com/perraco/utils/SnowflakeFactory.kt | 8 +- .../kotlin/com/storyteller_f/BaseTable.kt | 4 +- .../com/storyteller_f/DatabaseFactory.kt | 46 ++- .../index/ElasticTopicDocumentService.kt | 97 ++++-- .../index/LuceneTopicDocumentService.kt | 69 +++- .../com/storyteller_f/index/TopicDocument.kt | 13 +- .../media/FileSystemMediaService.kt | 10 +- .../com/storyteller_f/media/MediaService.kt | 6 +- .../storyteller_f/media/MinIoMediaService.kt | 34 +- .../com/storyteller_f/naming/NameService.kt | 9 +- .../com/storyteller_f/tables/Communities.kt | 4 +- .../storyteller_f/tables/CommunityJoins.kt | 17 +- .../storyteller_f/tables/EncryptedTopics.kt | 7 +- .../com/storyteller_f/tables/RoomJoins.kt | 17 +- .../kotlin/com/storyteller_f/tables/Rooms.kt | 36 +- .../kotlin/com/storyteller_f/tables/Topics.kt | 7 +- .../storyteller_f/types/PaginationResult.kt | 3 + backend/src/main/resources/charset | 8 +- cli/src/main/kotlin/com/storyteller_f/Add.kt | 14 +- .../main/com/storyteller_f/client_cli/main.kt | 173 ---------- .../a/client_lib/ClientCustomAuthProvider.kt | 12 +- .../storyteller_f/a/client_lib/HttpClient.kt | 6 +- .../com/storyteller_f/a/client_lib/Request.kt | 15 +- composeApp/build.gradle.kts | 3 + .../storyteller_f/a/app/preview/Community.kt | 3 +- .../com/storyteller_f/a/app/preview/Login.kt | 3 +- .../com/storyteller_f/a/app/preview/Topic.kt | 3 +- .../kotlin/com/storyteller_f/a/app/App.kt | 100 ++++-- .../com/storyteller_f/a/app/HomePage.kt | 50 ++- .../com/storyteller_f/a/app/LoginPage.kt | 11 +- .../a/app/community/CommunityPage.kt | 46 +-- .../a/app/community/CommunityRefCell.kt | 2 +- .../a/app/community/MyCommunitiesPage.kt | 21 +- .../storyteller_f/a/app/compontents/Dialog.kt | 11 +- .../storyteller_f/a/app/room/MyRoomsPage.kt | 23 +- .../com/storyteller_f/a/app/room/RoomPage.kt | 78 +++-- .../storyteller_f/a/app/room/RoomRefCell.kt | 2 +- .../a/app/search/CustomSearchBar.kt | 58 +++- .../com/storyteller_f/a/app/topic/RefRoute.kt | 47 +-- .../storyteller_f/a/app/topic/TopicCell.kt | 34 +- .../a/app/topic/TopicComposePage.kt | 115 +++++-- .../storyteller_f/a/app/topic/TopicPage.kt | 70 ++-- .../storyteller_f/a/app/world/WorldPage.kt | 17 +- config/detekt/detekt.yml | 2 +- dev.env | 8 +- dev.win.env | 2 +- gradle/libs.versions.toml | 3 + .../start-service-on-remote.sh | 8 + .../com/storyteller_f/a/server/Application.kt | 5 +- .../a/server/ProtectedContent.kt | 13 +- .../a/server/UnprotectedContent.kt | 55 ++-- .../com/storyteller_f/a/server/Websocket.kt | 124 +++---- .../com/storyteller_f/a/server/auth/Auth.kt | 183 ++++++----- .../a/server/common/Pagination.kt | 65 ++-- .../storyteller_f/a/server/common/Route.kt | 3 +- .../a/server/service/Community.kt | 116 ++++--- .../storyteller_f/a/server/service/Room.kt | 307 ++++++++---------- .../a/server/service/SearchWorld.kt | 53 +-- .../storyteller_f/a/server/service/Topic.kt | 280 ++++++++-------- .../storyteller_f/a/server/service/User.kt | 93 +++--- server/src/test/kotlin/CommunityTest.kt | 33 +- server/src/test/kotlin/SnowflakeTest.kt | 14 + server/src/test/kotlin/TestBuilder.kt | 2 + server/src/test/kotlin/TopicTest.kt | 59 ++++ .../storyteller_f/shared/type/PrimaryKey.kt | 8 +- .../com/storyteller_f/shared/utils/Result.kt | 56 ++++ 66 files changed, 1565 insertions(+), 1239 deletions(-) create mode 100644 backend/src/main/kotlin/com/storyteller_f/types/PaginationResult.kt delete mode 100644 client-cli/bin/main/com/storyteller_f/client_cli/main.kt create mode 100644 server/src/test/kotlin/SnowflakeTest.kt create mode 100644 server/src/test/kotlin/TopicTest.kt create mode 100644 shared/src/commonMain/kotlin/com/storyteller_f/shared/utils/Result.kt diff --git a/backend/src/main/kotlin/com/perraco/utils/SnowflakeFactory.kt b/backend/src/main/kotlin/com/perraco/utils/SnowflakeFactory.kt index bb0a20a..630b559 100644 --- a/backend/src/main/kotlin/com/perraco/utils/SnowflakeFactory.kt +++ b/backend/src/main/kotlin/com/perraco/utils/SnowflakeFactory.kt @@ -117,9 +117,9 @@ object SnowflakeFactory { // Construct the ID. - return (lastTimestampMs.toULong() shl (MACHINE_ID_BITS + SEQUENCE_BITS)) or - (machineId!!.toULong() shl SEQUENCE_BITS) or - sequence.toULong() + return (lastTimestampMs.toLong() shl (MACHINE_ID_BITS + SEQUENCE_BITS)) or + (machineId!!.toLong() shl SEQUENCE_BITS) or + sequence.toLong() } /** @@ -130,7 +130,7 @@ object SnowflakeFactory { */ fun parse(id: PrimaryKey): SnowflakeData { // Extract the machine ID segment. - val machineIdSegment = (id shr SEQUENCE_BITS) and MAX_MACHINE_ID.toULong() + val machineIdSegment = (id shr SEQUENCE_BITS) and MAX_MACHINE_ID.toLong() // Extract the timestamp segment. val timestampMs: Long = (id shr (MACHINE_ID_BITS + SEQUENCE_BITS)).toLong() diff --git a/backend/src/main/kotlin/com/storyteller_f/BaseTable.kt b/backend/src/main/kotlin/com/storyteller_f/BaseTable.kt index ad31480..0d097b8 100644 --- a/backend/src/main/kotlin/com/storyteller_f/BaseTable.kt +++ b/backend/src/main/kotlin/com/storyteller_f/BaseTable.kt @@ -6,10 +6,12 @@ import org.jetbrains.exposed.sql.Table import org.jetbrains.exposed.sql.kotlin.datetime.datetime abstract class BaseTable : Table() { - val id = ulong("id") + val id = customPrimaryKey("id") val createdTime = datetime("created_time").index() override val primaryKey = PrimaryKey(id) } abstract class BaseObj(val id: PrimaryKey, val createdTime: LocalDateTime) + +fun Table.customPrimaryKey(name: String) = long(name) diff --git a/backend/src/main/kotlin/com/storyteller_f/DatabaseFactory.kt b/backend/src/main/kotlin/com/storyteller_f/DatabaseFactory.kt index 91c2f5f..5a75974 100644 --- a/backend/src/main/kotlin/com/storyteller_f/DatabaseFactory.kt +++ b/backend/src/main/kotlin/com/storyteller_f/DatabaseFactory.kt @@ -3,6 +3,7 @@ package com.storyteller_f import com.storyteller_f.tables.* import kotlinx.coroutines.Dispatchers import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.statements.InsertStatement import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction import org.jetbrains.exposed.sql.transactions.transaction @@ -55,27 +56,40 @@ object DatabaseFactory { } } - suspend fun dbQuery(block: suspend () -> T): T = - newSuspendedTransaction(Dispatchers.IO) { block() } + suspend fun dbQuery(block: suspend () -> T): Result = + runCatching { + newSuspendedTransaction(Dispatchers.IO) { block() } + } /** * 带有transform */ - suspend fun query(transform: T.() -> R, block: suspend () -> T): R = + suspend fun query(transform: T.() -> R, block: suspend () -> T): Result = dbQuery { transform(block()) } /** * 处理可能查询不到数据的问题 */ - suspend fun queryNotNull(transform: T.() -> R?, block: suspend () -> T?): R? = + suspend fun queryNotNull(transform: T.() -> R?, block: suspend () -> T?): Result = query({ this?.let { transform(it) } }) { block() } + /** + * 处理可能查询不到数据的问题 + */ + suspend fun queryNotNull( + transform: R1.() -> R?, + resultRowTransform: (T) -> R1, + block: suspend () -> T? + ): Result = queryNotNull({ + resultRowTransform(this).transform() + }) { block() } + /** * 带有transform */ - suspend fun mapQuery(transform: T.() -> R, block: suspend () -> SizedIterable): List = + suspend fun mapQuery(transform: T.() -> R, block: suspend () -> SizedIterable): Result> = dbQuery { block().map(transform) } /** @@ -83,37 +97,41 @@ object DatabaseFactory { */ suspend fun mapQuery( transform: R1.() -> R, - typeTransform: (T) -> R1, + resultRowTransform: (T) -> R1, block: suspend () -> SizedIterable - ): List = + ): Result> = dbQuery { - block().map(typeTransform).map(transform) + block().map(resultRowTransform).map(transform) } /** * 查询第一个符合条件的数据 * * @param transform 转换数据 - * @param typeTransform 主要用于将ResultRow 转换成普通数据 + * @param resultRowTransform 主要用于将ResultRow 转换成普通数据 */ suspend fun first( transform: R1.() -> T, - typeTransform: (R) -> R1, + resultRowTransform: (R) -> R1, block: suspend () -> SizedIterable - ): T? = dbQuery { - block().limit(1).firstOrNull()?.let(typeTransform)?.let { transform(it) } + ): Result = dbQuery { + block().limit(1).firstOrNull()?.let(resultRowTransform)?.let { transform(it) } } /** * 检查数据是不是空 */ - suspend fun empty(block: suspend () -> SizedIterable): Boolean = dbQuery { + suspend fun isEmpty(block: suspend () -> SizedIterable): Result = dbQuery { block().limit(1).empty() } - suspend fun count(block: suspend () -> SizedIterable): Long = dbQuery { + suspend fun count(block: suspend () -> SizedIterable): Result = dbQuery { block().count() } + + suspend fun insert(block: suspend () -> InsertStatement): Result = dbQuery { + block().insertedCount + } } const val PUBLIC_KEY_LENGTH = 512 diff --git a/backend/src/main/kotlin/com/storyteller_f/index/ElasticTopicDocumentService.kt b/backend/src/main/kotlin/com/storyteller_f/index/ElasticTopicDocumentService.kt index b0aee59..eb836b6 100644 --- a/backend/src/main/kotlin/com/storyteller_f/index/ElasticTopicDocumentService.kt +++ b/backend/src/main/kotlin/com/storyteller_f/index/ElasticTopicDocumentService.kt @@ -1,12 +1,20 @@ package com.storyteller_f.index import co.elastic.clients.elasticsearch.ElasticsearchAsyncClient +import co.elastic.clients.elasticsearch._types.SortOrder +import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery +import co.elastic.clients.elasticsearch._types.query_dsl.MatchQuery +import co.elastic.clients.elasticsearch._types.query_dsl.Operator +import co.elastic.clients.elasticsearch._types.query_dsl.RangeQuery +import co.elastic.clients.elasticsearch.core.SearchRequest +import co.elastic.clients.json.JsonData import co.elastic.clients.json.jackson.JacksonJsonpMapper import co.elastic.clients.transport.TransportUtils import co.elastic.clients.transport.rest_client.RestClientTransport import com.fasterxml.jackson.module.kotlin.registerKotlinModule import com.storyteller_f.ElasticConnection import com.storyteller_f.shared.type.PrimaryKey +import com.storyteller_f.types.PaginationResult import io.github.aakira.napier.Napier import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.future.await @@ -20,8 +28,8 @@ import java.io.File import java.io.FileInputStream class ElasticTopicDocumentService(private val connection: ElasticConnection) : TopicDocumentService { - override suspend fun saveDocument(topics: List) { - useElasticClient(connection) { + override suspend fun saveDocument(topics: List): Result { + return useElasticClient(connection) { topics.map { topic -> index { it.index("topics").id(topic.id.toString()).document(topic) @@ -32,7 +40,7 @@ class ElasticTopicDocumentService(private val connection: ElasticConnection) : T } } - override suspend fun getDocument(idList: List): List { + override suspend fun getDocument(idList: List): Result> { return useElasticClient(connection) { idList.map { id -> get({ @@ -40,16 +48,59 @@ class ElasticTopicDocumentService(private val connection: ElasticConnection) : T .id(id.toString()) }, TopicDocument::class.java) }.map { - it.await().source() + it.await() + }.map { + it.source() } } } - override suspend fun clean() { - useElasticClient(connection) { + override suspend fun clean(): Result { + return useElasticClient(connection) { indices().delete { it.index("topics") - } + }.await() + Unit + } + } + + override suspend fun searchDocument( + word: List, + size: Int, + nextTopicId: PrimaryKey? + ): Result> { + val contentQuery = MatchQuery.of { m -> + m.field("content") + .query(word.joinToString(" ")) // 多关键字匹配,忽略大小写 + .operator(Operator.Or) + }._toQuery() + + val idRangeQuery = RangeQuery.of { r -> + r.field("id") + .lt(JsonData.of(nextTopicId ?: Long.MAX_VALUE)) + }._toQuery() + + val boolQuery = BoolQuery.of { b -> + b.must(contentQuery).must(idRangeQuery) + } + + // 构建排序条件:按 ID 升序排序 + val request = SearchRequest.of { s -> + s.index("topics") // 指定索引名称 + .query(boolQuery._toQuery()) + .sort { sort -> + sort.field { f -> + f.field("id").order(SortOrder.Asc) + } + }.trackScores(true) + } + return useElasticClient(connection) { + val response = search(request, TopicDocument::class.java).await() + val hits = response.hits() + val total = hits.total() + PaginationResult(hits.hits().mapNotNull { + it.source() + }, total?.value() ?: 0) } } } @@ -57,7 +108,7 @@ class ElasticTopicDocumentService(private val connection: ElasticConnection) : T private suspend fun useElasticClient( elasticConnection: ElasticConnection, block: suspend ElasticsearchAsyncClient.() -> T -): T { +): Result { val crtStream = withContext(Dispatchers.IO) { Napier.i(message = "cert path ${File(elasticConnection.certFile).canonicalPath}") FileInputStream(elasticConnection.certFile) @@ -70,20 +121,22 @@ private suspend fun useElasticClient( AuthScope.ANY, UsernamePasswordCredentials(elasticConnection.name, elasticConnection.pass) ) - return RestClient - .builder(HttpHost.create(elasticConnection.url)) - .setHttpClientConfigCallback { p0 -> - p0.setSSLContext(sslContext) - p0.setDefaultCredentialsProvider(credsProv) - } - .build().use { restClient -> - RestClientTransport( - restClient, - JacksonJsonpMapper().apply { - objectMapper().registerKotlinModule() + return runCatching { + RestClient + .builder(HttpHost.create(elasticConnection.url)) + .setHttpClientConfigCallback { p0 -> + p0.setSSLContext(sslContext) + p0.setDefaultCredentialsProvider(credsProv) + } + .build().use { restClient -> + RestClientTransport( + restClient, + JacksonJsonpMapper().apply { + objectMapper().registerKotlinModule() + } + ).use { transport -> + ElasticsearchAsyncClient(transport).block() } - ).use { transport -> - ElasticsearchAsyncClient(transport).block() } - } + } } diff --git a/backend/src/main/kotlin/com/storyteller_f/index/LuceneTopicDocumentService.kt b/backend/src/main/kotlin/com/storyteller_f/index/LuceneTopicDocumentService.kt index b82f2dd..9a0cb8d 100644 --- a/backend/src/main/kotlin/com/storyteller_f/index/LuceneTopicDocumentService.kt +++ b/backend/src/main/kotlin/com/storyteller_f/index/LuceneTopicDocumentService.kt @@ -1,6 +1,8 @@ package com.storyteller_f.index import com.storyteller_f.shared.type.PrimaryKey +import com.storyteller_f.shared.type.toPrimaryKeyOrNull +import com.storyteller_f.types.PaginationResult import io.github.aakira.napier.Napier import org.apache.lucene.analysis.standard.StandardAnalyzer import org.apache.lucene.document.* @@ -8,21 +10,22 @@ import org.apache.lucene.index.DirectoryReader import org.apache.lucene.index.IndexNotFoundException import org.apache.lucene.index.IndexWriter import org.apache.lucene.index.IndexWriterConfig -import org.apache.lucene.search.IndexSearcher -import org.apache.lucene.search.MatchAllDocsQuery +import org.apache.lucene.queryparser.classic.MultiFieldQueryParser +import org.apache.lucene.search.* import org.apache.lucene.store.FSDirectory import java.nio.file.Path class LuceneTopicDocumentService(private val path: Path) : TopicDocumentService { private val analyzer = StandardAnalyzer() - override suspend fun saveDocument(topics: List) { - FSDirectory.open(path).use { + override suspend fun saveDocument(topics: List): Result { + return useLucene { IndexWriter(it, IndexWriterConfig(analyzer)).use { writer -> val addDocuments = writer.addDocuments( topics.map { document -> val doc = Document() - doc.add(LongField("id", document.id.toLong(), Field.Store.YES)) + doc.add(LongField("id1", document.id.toLong(), Field.Store.YES)) + doc.add(NumericDocValuesField("id2", document.id)) doc.add(TextField("content", document.content, Field.Store.YES)) doc } @@ -34,14 +37,14 @@ class LuceneTopicDocumentService(private val path: Path) : TopicDocumentService } } - override suspend fun getDocument(idList: List): List { - if (idList.isEmpty()) return emptyList() - return FSDirectory.open(path).use { + override suspend fun getDocument(idList: List): Result> { + if (idList.isEmpty()) return Result.success(emptyList()) + return useLucene { try { DirectoryReader.open(it).use { reader -> val searcher = IndexSearcher(reader) idList.map { id -> - val topDocs = searcher.search(LongPoint.newExactQuery("id", id.toLong()), 1) + val topDocs = searcher.search(LongPoint.newExactQuery("id1", id.toLong()), 1) topDocs.scoreDocs.firstOrNull()?.let { scoreDoc -> searcher.storedFields().document(scoreDoc.doc).get("content")?.let { content -> TopicDocument(id, content) @@ -49,7 +52,7 @@ class LuceneTopicDocumentService(private val path: Path) : TopicDocumentService } } } - } catch (e: IndexNotFoundException) { + } catch (_: IndexNotFoundException) { List(idList.size) { null } @@ -57,11 +60,53 @@ class LuceneTopicDocumentService(private val path: Path) : TopicDocumentService } } - override suspend fun clean() { - FSDirectory.open(path).use { + override suspend fun clean(): Result { + return useLucene { IndexWriter(it, IndexWriterConfig(analyzer)).use { writer -> writer.deleteDocuments(MatchAllDocsQuery()) } } } + + override suspend fun searchDocument( + word: List, + size: Int, + nextTopicId: PrimaryKey? + ): Result> { + return useLucene { + DirectoryReader.open(it).use { reader -> + val searcher = IndexSearcher(reader) + val analyzer = StandardAnalyzer() + val combinedQuery = BooleanQuery + .Builder() + .add( + MultiFieldQueryParser(arrayOf("content"), analyzer).parse(word.joinToString(" ")), + BooleanClause.Occur.MUST + ).add( + LongPoint.newRangeQuery("id1", Long.MIN_VALUE, nextTopicId?.minus(1) ?: Long.MAX_VALUE), + BooleanClause.Occur.MUST + ).build() + val sortById = Sort(SortField("id2", SortField.Type.LONG, true)) + val docs = searcher.search(combinedQuery, size, sortById) + val scoreDocs = docs.scoreDocs + PaginationResult(scoreDocs.mapNotNull { + searcher.storedFields().document(it.doc).let { + val content = it.get("content") + val id = it.get("id1").toPrimaryKeyOrNull() + if (content != null && id != null) { + TopicDocument(id, content) + } else { + null + } + } + }, docs.totalHits.value) + } + } + } + + private fun useLucene(block: (FSDirectory) -> R): Result { + return runCatching { + FSDirectory.open(path).use(block) + } + } } diff --git a/backend/src/main/kotlin/com/storyteller_f/index/TopicDocument.kt b/backend/src/main/kotlin/com/storyteller_f/index/TopicDocument.kt index 5c4d1ed..fc2dfcb 100644 --- a/backend/src/main/kotlin/com/storyteller_f/index/TopicDocument.kt +++ b/backend/src/main/kotlin/com/storyteller_f/index/TopicDocument.kt @@ -1,13 +1,20 @@ package com.storyteller_f.index import com.storyteller_f.shared.type.PrimaryKey +import com.storyteller_f.types.PaginationResult data class TopicDocument(val id: PrimaryKey, val content: String) interface TopicDocumentService { - suspend fun saveDocument(topics: List) + suspend fun saveDocument(topics: List): Result - suspend fun getDocument(idList: List): List + suspend fun getDocument(idList: List): Result> - suspend fun clean() + suspend fun clean(): Result + + suspend fun searchDocument( + word: List, + size: Int, + nextTopicId: PrimaryKey? + ): Result> } diff --git a/backend/src/main/kotlin/com/storyteller_f/media/FileSystemMediaService.kt b/backend/src/main/kotlin/com/storyteller_f/media/FileSystemMediaService.kt index 69345e5..2e2ca5b 100644 --- a/backend/src/main/kotlin/com/storyteller_f/media/FileSystemMediaService.kt +++ b/backend/src/main/kotlin/com/storyteller_f/media/FileSystemMediaService.kt @@ -1,13 +1,13 @@ package com.storyteller_f.media class FileSystemMediaService : MediaService { - override fun upload(bucketName: String, list: List>) = Unit + override fun upload(bucketName: String, list: List>): Result = Result.success(Unit) - override fun get(bucketName: String, objList: List): List { - return objList.map { + override fun get(bucketName: String, objList: List): Result> { + return Result.success(objList.map { null - } + }) } - override fun clean(bucketName: String) = Unit + override fun clean(bucketName: String): Result = Result.success(Unit) } diff --git a/backend/src/main/kotlin/com/storyteller_f/media/MediaService.kt b/backend/src/main/kotlin/com/storyteller_f/media/MediaService.kt index 438b243..fb02320 100644 --- a/backend/src/main/kotlin/com/storyteller_f/media/MediaService.kt +++ b/backend/src/main/kotlin/com/storyteller_f/media/MediaService.kt @@ -1,9 +1,9 @@ package com.storyteller_f.media interface MediaService { - fun upload(bucketName: String, list: List>) + fun upload(bucketName: String, list: List>): Result - fun get(bucketName: String, objList: List): List + fun get(bucketName: String, objList: List): Result> - fun clean(bucketName: String) + fun clean(bucketName: String): Result } diff --git a/backend/src/main/kotlin/com/storyteller_f/media/MinIoMediaService.kt b/backend/src/main/kotlin/com/storyteller_f/media/MinIoMediaService.kt index e4ff2e0..eb26a60 100644 --- a/backend/src/main/kotlin/com/storyteller_f/media/MinIoMediaService.kt +++ b/backend/src/main/kotlin/com/storyteller_f/media/MinIoMediaService.kt @@ -6,15 +6,15 @@ import io.minio.http.Method import java.util.concurrent.TimeUnit class MinIoMediaService(private val connection: MinIoConnection) : MediaService { - override fun clean(bucketName: String) { - useMinIoClient(connection) { + override fun clean(bucketName: String): kotlin.Result { + return useMinIoClient(connection) { if (bucketExists(BucketExistsArgs.builder().bucket(bucketName).build())) { removeAllObject(bucketName) } } } - override fun get(bucketName: String, objList: List): List { + override fun get(bucketName: String, objList: List): kotlin.Result> { return useMinIoClient(connection) { objList.map { getIconInMioIo(bucketName, it) @@ -22,8 +22,8 @@ class MinIoMediaService(private val connection: MinIoConnection) : MediaService } } - override fun upload(bucketName: String, list: List>) { - useMinIoClient(connection) { + override fun upload(bucketName: String, list: List>): kotlin.Result { + return useMinIoClient(connection) { if (!bucketExists(BucketExistsArgs.builder().bucket(bucketName).build())) { makeBucket(MakeBucketArgs.builder().bucket(bucketName).build()) } @@ -36,18 +36,20 @@ class MinIoMediaService(private val connection: MinIoConnection) : MediaService } } -private fun useMinIoClient(minIoConnection: MinIoConnection, block: MinioClient.() -> R): R { - return MinioClient.builder() - .endpoint(minIoConnection.url) - .credentials(minIoConnection.user, minIoConnection.pass) - .build().use { - try { - it.block() - } catch (e: Exception) { - e.printStackTrace() - throw e +private fun useMinIoClient(minIoConnection: MinIoConnection, block: MinioClient.() -> R): kotlin.Result { + return runCatching { + MinioClient.builder() + .endpoint(minIoConnection.url) + .credentials(minIoConnection.user, minIoConnection.pass) + .build().use { + try { + it.block() + } catch (e: Exception) { + e.printStackTrace() + throw e + } } - } + } } private fun MinioClient.removeAllObject(bucketName: String) { diff --git a/backend/src/main/kotlin/com/storyteller_f/naming/NameService.kt b/backend/src/main/kotlin/com/storyteller_f/naming/NameService.kt index c40af47..4acfeff 100644 --- a/backend/src/main/kotlin/com/storyteller_f/naming/NameService.kt +++ b/backend/src/main/kotlin/com/storyteller_f/naming/NameService.kt @@ -5,14 +5,17 @@ import com.storyteller_f.shared.type.PrimaryKey class NameService { private val nameMap = mutableMapOf() + + // 存储合集累加的个数 private val countList = mutableListOf() init { this::class.java.classLoader.getResourceAsStream("charset")!!.bufferedReader().use { it.readText().split("\n").filterIndexed { index, element -> - index != 0 && !element.startsWith("//") + !element.startsWith("//") }.map { val split = it.trim().split(Regex("[ \\t]")) + // 个数以及范围 split[1].dropLast(1).toInt() to split[2].split("-") }.fold(0) { acc, i -> val count = acc + i.first @@ -24,13 +27,13 @@ class NameService { } } - fun parse(id: ULong): String { + fun parse(id: Long): String { return numberToCustomCharset(id) } // 将数字转换为自定义字符集表示的字符串 private fun numberToCustomCharset(num: PrimaryKey): String { - val base = countList.last().toULong() + val base = countList.last().toLong() var number = num diff --git a/backend/src/main/kotlin/com/storyteller_f/tables/Communities.kt b/backend/src/main/kotlin/com/storyteller_f/tables/Communities.kt index 44ab5e4..77eabfa 100644 --- a/backend/src/main/kotlin/com/storyteller_f/tables/Communities.kt +++ b/backend/src/main/kotlin/com/storyteller_f/tables/Communities.kt @@ -10,7 +10,7 @@ object Communities : BaseTable() { val aid = varchar("aid", COMMUNITY_ID_LENGTH).uniqueIndex() val name = varchar("name", COMMUNITY_NAME_LENGTH).index() val icon = varchar("icon", ICON_LENGTH).nullable() - val owner = ulong("owner").index() + val owner = customPrimaryKey("owner").index() val poster = varchar("poster", ICON_LENGTH).nullable() } @@ -47,7 +47,7 @@ class Community( } } - fun new(community: Community): ULong { + fun new(community: Community): PrimaryKey { val id = Communities.insert { it[id] = community.id it[name] = community.name diff --git a/backend/src/main/kotlin/com/storyteller_f/tables/CommunityJoins.kt b/backend/src/main/kotlin/com/storyteller_f/tables/CommunityJoins.kt index ca21d3c..58f91c8 100644 --- a/backend/src/main/kotlin/com/storyteller_f/tables/CommunityJoins.kt +++ b/backend/src/main/kotlin/com/storyteller_f/tables/CommunityJoins.kt @@ -1,6 +1,7 @@ package com.storyteller_f.tables import com.storyteller_f.DatabaseFactory +import com.storyteller_f.customPrimaryKey import com.storyteller_f.shared.type.PrimaryKey import org.jetbrains.exposed.sql.Table import org.jetbrains.exposed.sql.and @@ -8,8 +9,8 @@ import org.jetbrains.exposed.sql.kotlin.datetime.datetime import org.jetbrains.exposed.sql.selectAll object CommunityJoins : Table() { - val uid = ulong("uid").index() - val communityId = ulong("community_id").index() + val uid = customPrimaryKey("uid").index() + val communityId = customPrimaryKey("community_id").index() val joinTime = datetime("join_time").index() init { @@ -17,8 +18,14 @@ object CommunityJoins : Table() { } } -suspend fun isCommunityJoined(communityId: PrimaryKey, uid: PrimaryKey) = !DatabaseFactory.empty { - CommunityJoins.selectAll().where { - CommunityJoins.communityId eq communityId and (CommunityJoins.uid eq uid) +suspend fun isCommunityJoined(communityId: PrimaryKey, uid: PrimaryKey?) = if (uid == null) { + Result.success(false) +} else { + DatabaseFactory.isEmpty { + CommunityJoins.selectAll().where { + CommunityJoins.communityId eq communityId and (CommunityJoins.uid eq uid) + } + }.map { value -> + !value } } diff --git a/backend/src/main/kotlin/com/storyteller_f/tables/EncryptedTopics.kt b/backend/src/main/kotlin/com/storyteller_f/tables/EncryptedTopics.kt index 73e4528..218eb17 100644 --- a/backend/src/main/kotlin/com/storyteller_f/tables/EncryptedTopics.kt +++ b/backend/src/main/kotlin/com/storyteller_f/tables/EncryptedTopics.kt @@ -1,11 +1,12 @@ package com.storyteller_f.tables +import com.storyteller_f.customPrimaryKey import com.storyteller_f.shared.type.PrimaryKey import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.sql.Table object EncryptedTopics : Table() { - val topicId = ulong("topic_id") + val topicId = customPrimaryKey("topic_id") val content = blob("content") override val primaryKey = PrimaryKey(topicId) @@ -20,8 +21,8 @@ class EncryptedTopic(val topicId: PrimaryKey, val content: ByteArray) { } object EncryptedTopicKeys : Table() { - val topicId = ulong("topic_id") - val uid = ulong("uid") + val topicId = customPrimaryKey("topic_id") + val uid = customPrimaryKey("uid") val encryptedAes = blob("encrypted_aes") } diff --git a/backend/src/main/kotlin/com/storyteller_f/tables/RoomJoins.kt b/backend/src/main/kotlin/com/storyteller_f/tables/RoomJoins.kt index 1d3913b..c530941 100644 --- a/backend/src/main/kotlin/com/storyteller_f/tables/RoomJoins.kt +++ b/backend/src/main/kotlin/com/storyteller_f/tables/RoomJoins.kt @@ -1,6 +1,7 @@ package com.storyteller_f.tables import com.storyteller_f.DatabaseFactory +import com.storyteller_f.customPrimaryKey import com.storyteller_f.shared.type.PrimaryKey import com.storyteller_f.shared.utils.now import kotlinx.datetime.LocalDateTime @@ -8,8 +9,8 @@ import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.kotlin.datetime.datetime object RoomJoins : Table() { - val uid = ulong("uid").index() - val roomId = ulong("room_id").index() + val uid = customPrimaryKey("uid").index() + val roomId = customPrimaryKey("room_id").index() val joinTime = datetime("join_time").index() init { @@ -25,9 +26,15 @@ class RoomJoin(val uid: PrimaryKey, val roomId: PrimaryKey, val joinTime: LocalD } } -suspend fun isRoomJoined(roomId: PrimaryKey, uid: PrimaryKey) = !DatabaseFactory.empty { - RoomJoins.selectAll().where { - RoomJoins.roomId eq roomId and (RoomJoins.uid eq uid) +suspend fun isRoomJoined(roomId: PrimaryKey, uid: PrimaryKey?) = if (uid == null) { + Result.success(false) +} else { + DatabaseFactory.isEmpty { + RoomJoins.selectAll().where { + RoomJoins.roomId eq roomId and (RoomJoins.uid eq uid) + } + }.map { + !it } } diff --git a/backend/src/main/kotlin/com/storyteller_f/tables/Rooms.kt b/backend/src/main/kotlin/com/storyteller_f/tables/Rooms.kt index 59d63bd..17a288c 100644 --- a/backend/src/main/kotlin/com/storyteller_f/tables/Rooms.kt +++ b/backend/src/main/kotlin/com/storyteller_f/tables/Rooms.kt @@ -10,8 +10,8 @@ object Rooms : BaseTable() { val aid = varchar("aid", ROOM_ID_LENGTH).uniqueIndex() val name = varchar("name", ROOM_NAME_LENGTH).index() val icon = varchar("icon", ICON_LENGTH).nullable() - val creator = ulong("creator").index() - val communityId = ulong("community_id").index().nullable() + val creator = customPrimaryKey("creator").index() + val communityId = customPrimaryKey("community_id").index().nullable() } class Room( @@ -35,24 +35,26 @@ class Room( row[Rooms.createdTime] ) } - } -} -fun findRoomById(id: PrimaryKey): ResultRow? { - return Rooms.selectAll().where { - Rooms.id eq id - }.limit(1).firstOrNull() -} + fun findRoomById(id: PrimaryKey): ResultRow? { + return Rooms.selectAll().where { + Rooms.id eq id + }.limit(1).firstOrNull() + } -fun findRoomByAId(aid: String): ResultRow? { - return Rooms.selectAll().where { - Rooms.aid eq aid - }.limit(1).firstOrNull() + fun findRoomByAId(aid: String): ResultRow? { + return Rooms.selectAll().where { + Rooms.aid eq aid + }.limit(1).firstOrNull() + } + } } -fun checkRoomIsPrivate(roomId: PrimaryKey): Boolean { - val room = findRoomById(roomId)?.let { - Room.wrapRow(it) +suspend fun checkRoomIsPrivate(roomId: PrimaryKey): Result { + return DatabaseFactory.dbQuery { + val room = Room.findRoomById(roomId)?.let { + Room.wrapRow(it) + } + room?.communityId == null } - return room?.communityId == null } diff --git a/backend/src/main/kotlin/com/storyteller_f/tables/Topics.kt b/backend/src/main/kotlin/com/storyteller_f/tables/Topics.kt index 3d75ae0..97b23c1 100644 --- a/backend/src/main/kotlin/com/storyteller_f/tables/Topics.kt +++ b/backend/src/main/kotlin/com/storyteller_f/tables/Topics.kt @@ -2,6 +2,7 @@ package com.storyteller_f.tables import com.storyteller_f.BaseObj import com.storyteller_f.BaseTable +import com.storyteller_f.customPrimaryKey import com.storyteller_f.objectType import com.storyteller_f.shared.type.ObjectType import com.storyteller_f.shared.type.PrimaryKey @@ -13,10 +14,10 @@ import org.jetbrains.exposed.sql.kotlin.datetime.datetime import org.jetbrains.exposed.sql.selectAll object Topics : BaseTable() { - val author = ulong("author").index() - val parentId = ulong("parent_id") + val author = customPrimaryKey("author").index() + val parentId = customPrimaryKey("parent_id") val parentType = objectType("parent_type") - val rootId = ulong("root_id") + val rootId = customPrimaryKey("root_id") val rootType = objectType("root_type") val lastModifiedTime = datetime("last_modified_time").nullable() diff --git a/backend/src/main/kotlin/com/storyteller_f/types/PaginationResult.kt b/backend/src/main/kotlin/com/storyteller_f/types/PaginationResult.kt new file mode 100644 index 0000000..87cae3d --- /dev/null +++ b/backend/src/main/kotlin/com/storyteller_f/types/PaginationResult.kt @@ -0,0 +1,3 @@ +package com.storyteller_f.types + +data class PaginationResult(val list: List, val total: Long) diff --git a/backend/src/main/resources/charset b/backend/src/main/resources/charset index 1096193..7cb91a6 100644 --- a/backend/src/main/resources/charset +++ b/backend/src/main/resources/charset @@ -1,4 +1,4 @@ -https://www.zhangxinxu.com/study/201611/chinese-language-unicode-range.html +//https://www.zhangxinxu.com/study/201611/chinese-language-unicode-range.html //基本汉字 20902字 4E00-9FA5 //基本汉字补充 38字 9FA6-9FCB //扩展A 6582字 3400-4DB5 @@ -12,10 +12,10 @@ https://www.zhangxinxu.com/study/201611/chinese-language-unicode-range.html //PUA(GBK)部件 81字 E815-E86F //部件扩展 452字 E400-E5E8 //PUA增补 207字 E600-E6CF -汉字笔画 36字 31C0-31E3 +//汉字笔画 36字 31C0-31E3 //汉字结构 12字 2FF0-2FFB -汉语注音 22字 3105-3120 -注音扩展 22字 31A0-31BA +//汉语注音 22字 3105-3120 +//注音扩展 22字 31A0-31BA 〇 1字 3007 数字0-9 10字 30-39 小写英文字母 26字 61-7a diff --git a/cli/src/main/kotlin/com/storyteller_f/Add.kt b/cli/src/main/kotlin/com/storyteller_f/Add.kt index 7b635c7..256d8f8 100644 --- a/cli/src/main/kotlin/com/storyteller_f/Add.kt +++ b/cli/src/main/kotlin/com/storyteller_f/Add.kt @@ -150,14 +150,14 @@ class Add : Subcommand("add", "add entry") { val roomList = u.mapNotNull { it.room }.distinct().map { - Room.wrapRow(findRoomByAId(it)!!) + Room.wrapRow(Room.findRoomByAId(it)!!) }.groupBy { it.aid } val ids = insertTopicBaseLevel(u, userList, roomList) // 检查聊天室是属于社区的还是私有的 val roomIsPrivate = roomList.mapValues { (_, value) -> - checkRoomIsPrivate(value.first().id) + checkRoomIsPrivate(value.first().id).getOrThrow() } val topicsPrivate = u.mapIndexedNotNull { i, addTopic -> if (roomIsPrivate[addTopic.room] == true) { @@ -176,8 +176,8 @@ class Add : Subcommand("add", "add entry") { u: List, userList: Map>, roomList: Map> - ): ULongArray { - val ids = ULongArray(u.size) { + ): LongArray { + val ids = LongArray(u.size) { DEFAULT_PRIMARY_KEY } val topLevelTopic = u.mapIndexed { index, addTopic -> @@ -220,7 +220,7 @@ class Add : Subcommand("add", "add entry") { u: List, roomIsPrivate: Map, parentDir: File, - ids: ULongArray + ids: LongArray ) { val topicsPublic = u.mapIndexedNotNull { i, addTopic -> if (roomIsPrivate[addTopic.room] != true) { @@ -241,7 +241,7 @@ class Add : Subcommand("add", "add entry") { private suspend fun insertEncryptedTopic( topicsPrivate: List>, parentDir: File, - ids: ULongArray, + ids: LongArray, u: List ) { val rooms = u.mapNotNull { @@ -299,7 +299,7 @@ class Add : Subcommand("add", "add entry") { }.groupBy { it.second } - val ids = ULongArray(u.size) { + val ids = LongArray(u.size) { DEFAULT_PRIMARY_KEY } // 保存top 之前的层级关系 diff --git a/client-cli/bin/main/com/storyteller_f/client_cli/main.kt b/client-cli/bin/main/com/storyteller_f/client_cli/main.kt deleted file mode 100644 index 9e6f977..0000000 --- a/client-cli/bin/main/com/storyteller_f/client_cli/main.kt +++ /dev/null @@ -1,173 +0,0 @@ -package com.storyteller_f.client_cli - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import com.jakewharton.mosaic.LocalTerminal -import com.jakewharton.mosaic.layout.background -import com.jakewharton.mosaic.layout.height -import com.jakewharton.mosaic.layout.size -import com.jakewharton.mosaic.modifier.Modifier -import com.jakewharton.mosaic.runMosaicBlocking -import com.jakewharton.mosaic.text.SpanStyle -import com.jakewharton.mosaic.text.buildAnnotatedString -import com.jakewharton.mosaic.text.withStyle -import com.jakewharton.mosaic.ui.Box -import com.jakewharton.mosaic.ui.Color -import com.jakewharton.mosaic.ui.Column -import com.jakewharton.mosaic.ui.ColumnScope -import com.jakewharton.mosaic.ui.Filler -import com.jakewharton.mosaic.ui.Row -import com.jakewharton.mosaic.ui.Spacer -import com.jakewharton.mosaic.ui.Text -import com.jakewharton.mosaic.ui.TextStyle -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.delay - -private val BrightGreen = Color(100, 255, 100) -private val BrightBlue = Color(60, 140, 230) - -fun main() = runMosaicBlocking { - Column { - val terminal = LocalTerminal.current - Text( - buildAnnotatedString { - append("\uD83D\uDDA5\uFE0F") - append(" ") - append("Terminal(") - withStyle(SpanStyle(color = BrightGreen)) { - append("width=") - } - withStyle( - SpanStyle( - color = BrightBlue, - textStyle = TextStyle.Bold + TextStyle.Underline, - ), - ) { - append(terminal.size.width.toString()) - } - append(", ") - withStyle(SpanStyle(color = BrightGreen)) { - append("height=") - } - withStyle( - SpanStyle( - color = BrightBlue, - textStyle = TextStyle.Bold + TextStyle.Underline, - ), - ) { - append(terminal.size.height.toString()) - } - append(")") - append(" ") - append("\uD83D\uDDA5\uFE0F") - }, - ) - Spacer(modifier = Modifier.height(1)) - GradientsBlock() - } - - LaunchedEffect(Unit) { - awaitCancellation() - } -} - -@Suppress("UnusedReceiverParameter") // instead of ignore rule: compose:multiple-emitters-check -@Composable -private fun ColumnScope.GradientsBlock() { - val screenHalfWidth = LocalTerminal.current.size.width / 2 - var gradientWidth by remember { mutableIntStateOf(0) } - val gradientWidthDiff by remember(screenHalfWidth) { - derivedStateOf { (screenHalfWidth - gradientWidth) / 5 } - } - Gradient( - repeatedWord = "Red", - width = gradientWidth, - textColorProvider = { percent -> Color(1.0f - percent, 0.0f, 0.0f) }, - backgroundColorProvider = { percent -> Color(percent, 0.0f, 0.0f) }, - ) - Gradient( - repeatedWord = "Yellow", - width = gradientWidth, - textColorProvider = { percent -> Color(1.0f - percent, 1.0f - percent, 0.0f) }, - backgroundColorProvider = { percent -> Color(percent, percent, 0.0f) }, - ) - Gradient( - repeatedWord = "Green", - width = gradientWidth, - textColorProvider = { percent -> Color(0.0f, 1.0f - percent, 0.0f) }, - backgroundColorProvider = { percent -> Color(0.0f, percent, 0.0f) }, - ) - Gradient( - repeatedWord = "Cyan", - width = gradientWidth, - textColorProvider = { percent -> Color(0.0f, 1.0f - percent, 1.0f - percent) }, - backgroundColorProvider = { percent -> Color(0.0f, percent, percent) }, - ) - Gradient( - repeatedWord = "Blue", - width = gradientWidth, - textColorProvider = { percent -> Color(0.0f, 0.0f, 1.0f - percent) }, - backgroundColorProvider = { percent -> Color(0.0f, 0.0f, percent) }, - ) - Gradient( - repeatedWord = "Magenta", - width = gradientWidth, - textColorProvider = { percent -> Color(1.0f - percent, 0.0f, 1.0f - percent) }, - backgroundColorProvider = { percent -> Color(percent, 0.0f, percent) }, - ) - LaunchedEffect(screenHalfWidth) { - while (true) { - delay(100L) - gradientWidth += gradientWidthDiff - } - } -} - -@Composable -private fun Gradient( - repeatedWord: String, - width: Int, - textColorProvider: (percent: Float) -> Color, - backgroundColorProvider: (percent: Float) -> Color, -) { - var textBias by remember { mutableIntStateOf(0) } - Box { - Row { - var wordCharIndex = textBias - repeat(width) { index -> - if (wordCharIndex == repeatedWord.length) { - wordCharIndex = 0 - } - Filler( - char = repeatedWord[wordCharIndex], - foreground = textColorProvider.invoke(index / width.toFloat()), - modifier = Modifier.size(1), - ) - wordCharIndex++ - } - } - Row { - repeat(width) { index -> - Spacer( - modifier = Modifier - .size(1) - .background(backgroundColorProvider.invoke(index / width.toFloat())), - ) - } - } - } - LaunchedEffect(Unit) { - while (true) { - delay(200L) - textBias-- - if (textBias < 0) { - textBias = repeatedWord.length - 1 - } - } - } -} diff --git a/client-lib/src/commonMain/kotlin/com/storyteller_f/a/client_lib/ClientCustomAuthProvider.kt b/client-lib/src/commonMain/kotlin/com/storyteller_f/a/client_lib/ClientCustomAuthProvider.kt index 97d404f..324653e 100644 --- a/client-lib/src/commonMain/kotlin/com/storyteller_f/a/client_lib/ClientCustomAuthProvider.kt +++ b/client-lib/src/commonMain/kotlin/com/storyteller_f/a/client_lib/ClientCustomAuthProvider.kt @@ -63,12 +63,18 @@ fun HttpRequestBuilder.addRequestHeaders( data ?: return val state = LoginViewModel.state.value as? ClientSession.LoginSuccess if (state != null) { - val userId = LoginViewModel.user.value?.id + val userInfo = LoginViewModel.user.value + val userId = userInfo?.id val localData = LoginViewModel.session?.first val localSignature = LoginViewModel.session?.second if (data == localData && localData.isNotBlank() && !localSignature.isNullOrBlank() && userId != null) { - headers[HttpHeaders.Authorization] = - """Custom id="$userId", sig="$localSignature"""" + if (userInfo.aid.isNullOrBlank()) { + headers[HttpHeaders.Authorization] = + """Custom id="$userId", sig="$localSignature"""" + } else { + headers[HttpHeaders.Authorization] = + """Custom aid="${userInfo.aid}", sig="$localSignature"""" + } } } } diff --git a/client-lib/src/commonMain/kotlin/com/storyteller_f/a/client_lib/HttpClient.kt b/client-lib/src/commonMain/kotlin/com/storyteller_f/a/client_lib/HttpClient.kt index 72b4fac..492f105 100644 --- a/client-lib/src/commonMain/kotlin/com/storyteller_f/a/client_lib/HttpClient.kt +++ b/client-lib/src/commonMain/kotlin/com/storyteller_f/a/client_lib/HttpClient.kt @@ -38,7 +38,11 @@ fun HttpClientConfig<*>.defaultClientConfigure() { install(HttpCookies) install(HttpRequestRetry) { retryIf { request, response -> - response.status == HttpStatusCode.Unauthorized && request.headers["cookie"].isNullOrEmpty() + response.status == HttpStatusCode.Unauthorized && request.headers["cookie"].isNullOrEmpty() || + response.status == HttpStatusCode.TooManyRequests + } + delayMillis { + 0 } } install(WebSockets) { diff --git a/client-lib/src/commonMain/kotlin/com/storyteller_f/a/client_lib/Request.kt b/client-lib/src/commonMain/kotlin/com/storyteller_f/a/client_lib/Request.kt index 07f2c4f..3aef5ef 100644 --- a/client-lib/src/commonMain/kotlin/com/storyteller_f/a/client_lib/Request.kt +++ b/client-lib/src/commonMain/kotlin/com/storyteller_f/a/client_lib/Request.kt @@ -22,8 +22,12 @@ suspend fun HttpClient.requestRoomInfoByAid(aid: String) = get("room") { } }.body() -suspend fun HttpClient.requestRoomKeys(id: PrimaryKey) = - get("room/$id/pub-keys").body>>() +suspend fun HttpClient.requestRoomKeys(id: PrimaryKey, nextId: PrimaryKey?, size: Int) = + get("room/$id/pub-keys") { + url { + appendPagingQueryParams(size, nextId) + } + }.body>>() suspend fun HttpClient.joinRoom(id: PrimaryKey) = post("room/$id/join") @@ -150,3 +154,10 @@ suspend fun HttpClient.verifySnapshot(pack: TopicSnapshotPack) = post("topic/ver contentType(ContentType.Application.Json) setBody(pack) } + +suspend fun HttpClient.searchTopics(nextTopicId: PrimaryKey?, size: Int, word: List) = get("/topic/search", { + url { + parameters.appendAll("word", word) + appendPagingQueryParams(size, nextTopicId) + } +}).body>() diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 514ff94..e2320b1 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -124,6 +124,7 @@ kotlin { implementation(libs.multiplatform.markdown.renderer.m3) implementation(libs.multiplatform.markdown.renderer.coil3) implementation(libs.uri.kmp) + implementation(libs.sonner) } commonTest.dependencies { implementation(kotlin("test")) @@ -202,6 +203,7 @@ android { } } compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } @@ -209,6 +211,7 @@ android { compose = true } dependencies { + coreLibraryDesugaring(libs.desugar.jdk.libs) debugImplementation(compose.uiTooling) } } diff --git a/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/preview/Community.kt b/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/preview/Community.kt index 68eb326..71c7346 100644 --- a/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/preview/Community.kt +++ b/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/preview/Community.kt @@ -24,8 +24,7 @@ private fun PreviewCommunity(@PreviewParameter(CommunityProvider::class) communi @Composable private fun PreviewCommunityPage() { Column { - CustomSearchBar({ - }) { + CustomSearchBar { } CustomBottomNav(null, navRoutes = communityNavRoutes()) } diff --git a/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/preview/Login.kt b/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/preview/Login.kt index fe87719..cb7f5e6 100644 --- a/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/preview/Login.kt +++ b/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/preview/Login.kt @@ -13,6 +13,5 @@ private fun PreviewLogin() { @Preview @Composable private fun PreviewPrivateKey() { - InputPrivateKeyPage { - } + InputPrivateKeyPage() } diff --git a/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/preview/Topic.kt b/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/preview/Topic.kt index 26e3798..418ff69 100644 --- a/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/preview/Topic.kt +++ b/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/preview/Topic.kt @@ -56,8 +56,7 @@ private class TopicPagePreviewProvider : PreviewParameterProvider>) { val topic = param.first Column { - CustomSearchBar({ - }) { + CustomSearchBar { Icon(Icons.Default.Topic, "topic", modifier = Modifier.clickable { }) } diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/App.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/App.kt index 774be62..39e3bd6 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/App.kt @@ -1,6 +1,8 @@ package com.storyteller_f.a.app import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember import coil3.ImageLoader import coil3.PlatformContext @@ -16,6 +18,7 @@ import com.storyteller_f.a.app.compontents.EventState import com.storyteller_f.a.app.room.RoomPage import com.storyteller_f.a.app.topic.TopicComposePage import com.storyteller_f.a.app.topic.TopicPage +import com.storyteller_f.a.app.topic.processEncryptedTopic import com.storyteller_f.a.app.ui.theme.AppTheme import com.storyteller_f.a.client_lib.ClientWebSocket import com.storyteller_f.a.client_lib.LoginViewModel @@ -26,6 +29,7 @@ import com.storyteller_f.shared.obj.RoomFrame import com.storyteller_f.shared.type.ObjectType import com.storyteller_f.shared.type.ObjectType.* import com.storyteller_f.shared.type.PrimaryKey +import com.storyteller_f.shared.type.toPrimaryKey import io.github.aakira.napier.DebugAntilog import io.github.aakira.napier.Napier import io.ktor.client.* @@ -47,6 +51,10 @@ object StaticObj { val globalDialogState = EventState() +val LocalAppNav = compositionLocalOf { + AppNav.EMPTY +} + @OptIn(ExperimentalCoilApi::class) @Composable fun App() { @@ -55,57 +63,46 @@ fun App() { setSingletonImageLoaderFactory { getAsyncImageLoader(it) } - EventDialog(globalDialogState) - PreComposeApp { - val navigator = rememberNavigator() - val appNav = remember { - newAppNav(navigator) - } - val onClick = { id: PrimaryKey, type: ObjectType -> - when (type) { - COMMUNITY -> appNav.gotoCommunity(id) - ROOM -> appNav.gotoRoom(id) - TOPIC -> appNav.gotoTopic(id) - USER -> { - } + val navigator = rememberNavigator() + val appNav = remember { + newAppNav(navigator) + } + CompositionLocalProvider(LocalAppNav provides appNav) { + EventDialog(globalDialogState) + PreComposeApp { + NavHost(navigator, initialRoute = "/home") { + buildRootNav(navigator) } } - NavHost(navigator, initialRoute = "/home") { - buildRootNav(appNav, onClick, navigator) - } } } } private fun RouteBuilder.buildRootNav( - appNav: AppNav, - onClick: (PrimaryKey, ObjectType) -> Unit, navigator: Navigator ) { scene("/home") { - HomePage(appNav, onClick) + HomePage() } scene("/login") { - LoginPage(appNav::gotoHome) + LoginPage() } scene("/community/{communityId}") { val communityId = it.path2("communityId", null) if (communityId != null) { - CommunityPage(communityId, appNav::gotoLogin, { - appNav.gotoTopicCompose(COMMUNITY, communityId) - }, onClick) + CommunityPage(communityId) } } scene("/room/{roomId}") { val roomId = it.path2("roomId", null) if (roomId != null) { - RoomPage(roomId, appNav::gotoLogin, onClick) + RoomPage(roomId) } } scene("/topic/{topicId}") { val topicId = it.path2("topicId", null) if (topicId != null) { - TopicPage(topicId, appNav::gotoLogin, onClick) + TopicPage(topicId) } } scene("/topic-compose/{objectType}/{objectId}") { @@ -166,15 +163,15 @@ val clientWs by lazy { } }) { if (it is RoomFrame.NewTopicInfo) { - val topicInfo = it.topicInfo - getOrCreateCollection("topics").save( + val info = processEncryptedTopic(listOf(it.topicInfo)).first() + getOrCreateCollection("topics${info.parentId}").save( MutableDocument( - topicInfo.id.toString(), - Json.encodeToString(topicInfo) + info.id.toString(), + Json.encodeToString(info) ) ) Napier.v(tag = "pagination") { - "save document $topicInfo" + "save document $info" } } } @@ -203,9 +200,50 @@ interface AppNav { fun gotoHome() fun gotoTopicCompose(objectType: ObjectType, objectId: PrimaryKey) + + fun goto(id: PrimaryKey, type: ObjectType) { + when (type) { + COMMUNITY -> gotoCommunity(id) + ROOM -> gotoRoom(id) + TOPIC -> gotoTopic(id) + USER -> { + } + } + } + + companion object { + val EMPTY = object : AppNav { + override fun gotoLogin() { + TODO("Not yet implemented") + } + + override fun gotoRoom(roomId: PrimaryKey) { + TODO("Not yet implemented") + } + + override fun gotoCommunity(communityId: PrimaryKey) { + TODO("Not yet implemented") + } + + override fun gotoTopic(topicId: PrimaryKey) { + TODO("Not yet implemented") + } + + override fun gotoHome() { + TODO("Not yet implemented") + } + + override fun gotoTopicCompose( + objectType: ObjectType, + objectId: PrimaryKey + ) { + TODO("Not yet implemented") + } + } + } } inline fun BackStackEntry.path2(path: String, default: T? = null): T? { val value = pathMap[path] ?: return default - return if (T::class == PrimaryKey::class) value.toULong() as T else convertValue(value) + return if (T::class == PrimaryKey::class) value.toPrimaryKey() as T else convertValue(value) } diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/HomePage.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/HomePage.kt index d34ecd7..34bf5fe 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/HomePage.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/HomePage.kt @@ -36,15 +36,13 @@ import com.storyteller_f.a.app.search.CustomSearchBar import com.storyteller_f.a.app.world.WorldPage import com.storyteller_f.a.client_lib.LoginViewModel import com.storyteller_f.shared.model.UserInfo -import com.storyteller_f.shared.type.ObjectType -import com.storyteller_f.shared.type.PrimaryKey import kotlinx.coroutines.launch import moe.tlaster.precompose.navigation.* import moe.tlaster.precompose.navigation.transition.NavTransition @Composable @OptIn(ExperimentalMaterial3WindowSizeClassApi::class, ExperimentalFoundationApi::class) -fun HomePage(appNav: AppNav, onClick: (PrimaryKey, ObjectType) -> Unit = { _, _ -> }) { +fun HomePage() { val size = calculateWindowSizeClass() val homeNavs = listOf( NavRoute("/world", Icons.Default.Public, "world"), @@ -59,13 +57,13 @@ fun HomePage(appNav: AppNav, onClick: (PrimaryKey, ObjectType) -> Unit = { _, _ Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { - CustomSearchBar(appNav::gotoLogin) { + CustomSearchBar { ProjectIcon() } val pagerState = rememberPagerState { 3 } - HomePager(Modifier.weight(1f), appNav, pagerState, onClick) + HomePager(Modifier.weight(1f), pagerState) val scope = rememberCoroutineScope() CustomBottomNav(homeNavs[pagerState.currentPage].path, homeNavs) { path -> scope.launch { @@ -84,7 +82,7 @@ fun HomePage(appNav: AppNav, onClick: (PrimaryKey, ObjectType) -> Unit = { _, _ CustomRailNav(currentEntry, homeNavs) { navigator.navigate(it, NavOptions(launchSingleTop = true)) } - HomeNavHost(navigator, modifier = Modifier.weight(1f), appNav::gotoLogin, onClick) + HomeNavHost(navigator, modifier = Modifier.weight(1f)) } } } @@ -159,12 +157,10 @@ fun CustomBottomNav( @Composable private fun HomeNavHost( navigator: Navigator, - modifier: Modifier, - onLogin: () -> Unit, - onClick: (PrimaryKey, ObjectType) -> Unit + modifier: Modifier ) { Column(modifier = modifier) { - CustomSearchBar(onLogin) { + CustomSearchBar { ProjectIcon() } NavHost(navigator, initialRoute = "/world", modifier = modifier, navTransition = remember { @@ -180,20 +176,16 @@ private fun HomeNavHost( ) }) { scene("/world") { - WorldPage(onClick) + WorldPage() } scene("/communities") { - UserHost(onLogin) { - MyCommunitiesPage { - onClick(it, ObjectType.COMMUNITY) - } + UserHost { + MyCommunitiesPage() } } scene("/rooms") { - UserHost(onLogin) { - MyRoomsPage { - onClick(it, ObjectType.ROOM) - } + UserHost { + MyRoomsPage() } } } @@ -204,26 +196,26 @@ private fun HomeNavHost( @Composable private fun HomePager( modifier: Modifier, - appNav: AppNav, - pagerState: PagerState, - onClick: (PrimaryKey, ObjectType) -> Unit + pagerState: PagerState ) { + LocalAppNav.current HorizontalPager(pagerState, modifier) { when (it) { - 0 -> WorldPage(onClick) - 1 -> UserHost(appNav::gotoLogin) { - MyCommunitiesPage(appNav::gotoCommunity) + 0 -> WorldPage() + 1 -> UserHost { + MyCommunitiesPage() } - else -> UserHost(appNav::gotoLogin) { - MyRoomsPage(appNav::gotoRoom) + else -> UserHost { + MyRoomsPage() } } } } @Composable -private fun UserHost(onClickLogin: () -> Unit, content: @Composable (UserInfo) -> Unit) { +private fun UserHost(content: @Composable (UserInfo) -> Unit) { + val appNav = LocalAppNav.current val user by LoginViewModel.user.collectAsState() val localUser = user if (localUser != null) { @@ -231,7 +223,7 @@ private fun UserHost(onClickLogin: () -> Unit, content: @Composable (UserInfo) - } else { CenterBox { Button({ - onClickLogin() + appNav.gotoLogin() }) { Text("Login") } diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/LoginPage.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/LoginPage.kt index eeeb45b..c4c20a4 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/LoginPage.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/LoginPage.kt @@ -9,10 +9,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import com.russhwolf.settings.ExperimentalSettingsApi -import com.russhwolf.settings.get import com.russhwolf.settings.serialization.decodeValueOrNull import com.russhwolf.settings.serialization.encodeValue -import com.russhwolf.settings.set import com.storyteller_f.a.app.common.CenterBox import com.storyteller_f.a.app.compontents.MeasureTextLineCount import com.storyteller_f.a.app.compontents.use @@ -31,7 +29,7 @@ import moe.tlaster.precompose.navigation.NavHost import moe.tlaster.precompose.navigation.rememberNavigator @Composable -fun LoginPage(onLoginSuccess: () -> Unit) { +fun LoginPage() { val navigator = rememberNavigator() val nav = remember { object : LoginNav { @@ -58,7 +56,7 @@ fun LoginPage(onLoginSuccess: () -> Unit) { SelectSignupPage(nav) } scene("/k") { - InputPrivateKeyPage(onLoginSuccess) + InputPrivateKeyPage() } } } @@ -102,10 +100,11 @@ fun SelectSignupPage(loginNav: LoginNav) { } @Composable -fun InputPrivateKeyPage(onLoginSuccess: () -> Unit) { +fun InputPrivateKeyPage() { val privateKey by LoginViewModel.privateKey.collectAsState("") val isSignUp by LoginViewModel.isSignUp.collectAsState(false) val scope = rememberCoroutineScope() + val appNav = LocalAppNav.current CenterBox { Column(modifier = Modifier.padding(20.dp)) { @@ -119,7 +118,7 @@ fun InputPrivateKeyPage(onLoginSuccess: () -> Unit) { Button({ if (privateKey.isNotBlank()) { scope.launch { - globalDialogState.use(onLoginSuccess) { + globalDialogState.use(appNav::gotoHome) { val data = client.getData() val f = finalData(data) val sig = signature(privateKey, f) diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/community/CommunityPage.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/community/CommunityPage.kt index 2e3e2ca..5101ae2 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/community/CommunityPage.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/community/CommunityPage.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.viewModelScope import androidx.paging.ExperimentalPagingApi import app.cash.paging.compose.collectAsLazyPagingItems import com.storyteller_f.a.app.CustomBottomNav +import com.storyteller_f.a.app.LocalAppNav import com.storyteller_f.a.app.NavRoute import com.storyteller_f.a.app.bus import com.storyteller_f.a.app.client @@ -31,14 +32,15 @@ import com.storyteller_f.a.app.common.* import com.storyteller_f.a.app.compontents.* import com.storyteller_f.a.app.globalDialogState import com.storyteller_f.a.app.room.RoomList +import com.storyteller_f.a.app.room.TopicsViewModel import com.storyteller_f.a.app.search.CustomSearchBar import com.storyteller_f.a.app.world.TopicList import com.storyteller_f.a.client_lib.* import com.storyteller_f.shared.model.CommunityInfo import com.storyteller_f.shared.model.RoomInfo -import com.storyteller_f.shared.model.TopicInfo import com.storyteller_f.shared.type.ObjectType import com.storyteller_f.shared.type.PrimaryKey +import com.storyteller_f.shared.type.toPrimaryKeyOrNull import io.ktor.client.* import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -77,24 +79,13 @@ class CommunityViewModel(private val requestInfo: suspend HttpClient.() -> Commu } } -@OptIn(ExperimentalPagingApi::class) -class CommunityTopicsViewModel(private val communityId: PrimaryKey) : PagingViewModel({ - SimplePagingSource { - serviceCatching { - client.getCommunityTopics(communityId, it, 10) - }.map { - APagingData(it.data, it.pagination?.nextPageToken?.toULongOrNull()) - } - } -}) - @OptIn(ExperimentalPagingApi::class) class CommunityRoomsViewModel(private val communityId: PrimaryKey) : PagingViewModel({ SimplePagingSource { serviceCatching { client.getCommunityRooms(communityId, it, 10) }.map { - APagingData(it.data, it.pagination?.nextPageToken?.toULongOrNull()) + APagingData(it.data, it.pagination?.nextPageToken?.toPrimaryKeyOrNull()) } } }) @@ -102,11 +93,9 @@ class CommunityRoomsViewModel(private val communityId: PrimaryKey) : PagingViewM @OptIn(ExperimentalFoundationApi::class) @Composable fun CommunityPage( - communityId: PrimaryKey, - onClickAddTopic: () -> Unit, - onLogin: () -> Unit, - onClick: (PrimaryKey, ObjectType) -> Unit + communityId: PrimaryKey ) { + val appNav = LocalAppNav.current val model = viewModel(CommunityViewModel::class, keys = listOf("community", communityId)) { CommunityViewModel(communityId) } @@ -119,7 +108,7 @@ fun CommunityPage( Scaffold(floatingActionButton = { FloatingActionButton(onClick = { if (community?.isJoined == true) { - onClickAddTopic() + appNav.gotoTopicCompose(ObjectType.COMMUNITY, communityId) } else { globalDialogState.showMessage("Not joined!") } @@ -134,15 +123,15 @@ fun CommunityPage( }) } } - }) { paddingValues -> + }) { Column( - modifier = Modifier.padding(paddingValues).consumeWindowInsets(WindowInsets.statusBars), + modifier = Modifier.padding(bottom = it.calculateBottomPadding()), ) { - CustomSearchBar(onLogin) { + CustomSearchBar { CommunityIcon(community, 40.dp) } - CommunityPageInternal(pagerState, communityId, onClick) + CommunityPageInternal(pagerState, communityId) } } } @@ -151,20 +140,19 @@ fun CommunityPage( @OptIn(ExperimentalFoundationApi::class) private fun CommunityPageInternal( pagerState: PagerState, - communityId: PrimaryKey, - onClick: (PrimaryKey, ObjectType) -> Unit + communityId: PrimaryKey ) { HorizontalPager(pagerState) { when (it) { 0 -> { val viewModel = viewModel( - CommunityTopicsViewModel::class, + TopicsViewModel::class, keys = listOf("community-topics", communityId) ) { - CommunityTopicsViewModel(communityId) + TopicsViewModel(communityId, ObjectType.COMMUNITY) } val items = viewModel.flow.collectAsLazyPagingItems() - TopicList(items, onClick) + TopicList(items) } else -> { @@ -173,9 +161,7 @@ private fun CommunityPageInternal( CommunityRoomsViewModel(communityId) } val items = viewModel.flow.collectAsLazyPagingItems() - RoomList(items) { roomId -> - onClick(roomId, ObjectType.ROOM) - } + RoomList(items) } } } diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/community/CommunityRefCell.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/community/CommunityRefCell.kt index 0ea6f53..ef7dfc9 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/community/CommunityRefCell.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/community/CommunityRefCell.kt @@ -49,6 +49,6 @@ private fun CommunityRefCellInternal(viewModel: CommunityViewModel, onClick: (Pr } .padding(10.dp) ) { - CommunityCell(it, true, onClick) + CommunityCell(it, true) } } diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/community/MyCommunitiesPage.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/community/MyCommunitiesPage.kt index 11a461b..3b9bbd3 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/community/MyCommunitiesPage.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/community/MyCommunitiesPage.kt @@ -15,6 +15,7 @@ import androidx.paging.ExperimentalPagingApi import app.cash.paging.compose.collectAsLazyPagingItems import app.cash.paging.compose.itemContentType import app.cash.paging.compose.itemKey +import com.storyteller_f.a.app.LocalAppNav import com.storyteller_f.a.app.client import com.storyteller_f.a.app.common.* import com.storyteller_f.a.app.compontents.CommunityIcon @@ -22,9 +23,10 @@ import com.storyteller_f.a.app.utils.lcm import com.storyteller_f.a.client_lib.getJoinCommunities import com.storyteller_f.shared.model.CommunityInfo import com.storyteller_f.shared.type.PrimaryKey +import com.storyteller_f.shared.type.toPrimaryKeyOrNull @Composable -fun MyCommunitiesPage(onClick: (PrimaryKey) -> Unit) { +fun MyCommunitiesPage() { val viewModel = viewModel(MyCommunitiesViewModel::class) { MyCommunitiesViewModel() } @@ -53,8 +55,8 @@ fun MyCommunitiesPage(onClick: (PrimaryKey) -> Unit) { ) { index -> val communityInfo = items[index] when { - communityInfo?.poster != null -> CommunityGrid(communityInfo, onClick) - else -> CommunityCell(communityInfo, false, onClick) + communityInfo?.poster != null -> CommunityGrid(communityInfo) + else -> CommunityCell(communityInfo, false) } } } @@ -68,7 +70,7 @@ class MyCommunitiesViewModel : PagingViewModel({ serviceCatching { client.getJoinCommunities(it, 10) }.map { - APagingData(it.data, it.pagination?.nextPageToken?.toULongOrNull()) + APagingData(it.data, it.pagination?.nextPageToken?.toPrimaryKeyOrNull()) } } }) @@ -84,13 +86,14 @@ fun CommunityConstrains(modifier: Modifier = Modifier, content: @Composable (Int } @Composable -fun CommunityGrid(communityInfo: CommunityInfo?, onClick: (PrimaryKey) -> Unit = {}) { +fun CommunityGrid(communityInfo: CommunityInfo?) { + val appNav = LocalAppNav.current Box( modifier = Modifier .fillMaxWidth() .aspectRatio(3f / 4) .clickable { - communityInfo?.let { onClick(it.id) } + communityInfo?.let { appNav.gotoCommunity(it.id) } } ) { Box( @@ -118,9 +121,9 @@ fun CommunityGrid(communityInfo: CommunityInfo?, onClick: (PrimaryKey) -> Unit = @Composable fun CommunityCell( communityInfo: CommunityInfo?, - customBackground: Boolean = false, - onClick: (PrimaryKey) -> Unit = {} + customBackground: Boolean = false ) { + val appNav = LocalAppNav.current Row( modifier = when { customBackground -> Modifier @@ -128,7 +131,7 @@ fun CommunityCell( .fillMaxWidth() .background(MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(10.dp)) .clickable { - communityInfo?.id?.let { onClick(it) } + communityInfo?.id?.let { appNav.gotoCommunity(it) } } .padding(10.dp) }, diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/compontents/Dialog.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/compontents/Dialog.kt index 7d1d229..dd3d854 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/compontents/Dialog.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/compontents/Dialog.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties +import com.storyteller_f.a.app.BuildKonfig sealed interface DialogState { data object Loading : DialogState @@ -102,9 +103,11 @@ fun EventAlertDialog(message: DialogState, updateNewState: (DialogState) -> Unit }, title = { Text(throwable.message ?: throwable::class.simpleName ?: throwable.toString()) }, text = { - val text = throwable.stackTraceToString() - MeasureTextLineCount(text, LocalTextStyle.current, 0.dp) { _, total -> - Text(text, modifier = Modifier.verticalScroll(scrollState), maxLines = (total).coerceIn(2, 20)) + if (!BuildKonfig.IS_PROD) { + val text = throwable.stackTraceToString() + MeasureTextLineCount(text, LocalTextStyle.current, 0.dp) { _, total -> + Text(text, modifier = Modifier.verticalScroll(scrollState), maxLines = (total).coerceIn(2, 20)) + } } }) } @@ -134,7 +137,7 @@ fun ButtonNav(icon: ImageVector, title: String, onClick: () -> Unit = {}) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.clickable { + modifier = Modifier.fillMaxWidth().clickable { onClick() }.padding(8.dp) ) { diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/room/MyRoomsPage.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/room/MyRoomsPage.kt index 0aa3e16..caeefe7 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/room/MyRoomsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/room/MyRoomsPage.kt @@ -19,21 +19,24 @@ import app.cash.paging.compose.collectAsLazyPagingItems import app.cash.paging.compose.itemContentType import app.cash.paging.compose.itemKey import coil3.compose.AsyncImage +import com.storyteller_f.a.app.LocalAppNav import com.storyteller_f.a.app.client import com.storyteller_f.a.app.common.* import com.storyteller_f.a.app.common.viewModel import com.storyteller_f.a.app.utils.safeFirstUnicode import com.storyteller_f.a.client_lib.getJoinedRooms import com.storyteller_f.shared.model.RoomInfo +import com.storyteller_f.shared.type.ObjectType import com.storyteller_f.shared.type.PrimaryKey +import com.storyteller_f.shared.type.toPrimaryKey @Composable -fun MyRoomsPage(onClick: (PrimaryKey) -> Unit) { +fun MyRoomsPage() { val viewModel = viewModel(MyRoomsViewModel::class) { MyRoomsViewModel() } val items = viewModel.flow.collectAsLazyPagingItems() - RoomList(items, onClick) + RoomList(items) } @OptIn(ExperimentalPagingApi::class) @@ -42,15 +45,14 @@ class MyRoomsViewModel : PagingViewModel({ serviceCatching { client.getJoinedRooms(10, it) }.map { - APagingData(it.data, it.pagination?.nextPageToken?.toULongOrNull()) + APagingData(it.data, it.pagination?.nextPageToken?.toPrimaryKey()) } } }) @Composable fun RoomList( - items: LazyPagingItems, - onClick: (PrimaryKey) -> Unit + items: LazyPagingItems ) { StateView(items) { LazyColumn( @@ -65,14 +67,19 @@ fun RoomList( }, contentType = items.itemContentType() ) { index -> - RoomCell(items[index], false, onClick) + RoomCell(items[index], false) } } } } @Composable -fun RoomCell(roomInfo: RoomInfo?, customBackground: Boolean = false, onClick: (PrimaryKey) -> Unit = {}) { +fun RoomCell( + roomInfo: RoomInfo?, + customBackground: Boolean = false +) { + val appNav = LocalAppNav.current + val onClick = appNav::goto var showDialog by remember { mutableStateOf(false) } @@ -83,7 +90,7 @@ fun RoomCell(roomInfo: RoomInfo?, customBackground: Boolean = false, onClick: (P else -> Modifier.fillMaxWidth() .background(MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(10.dp)) .clickable { - roomInfo?.let { onClick(it.id) } + roomInfo?.let { onClick(it.id, ObjectType.ROOM) } } .padding(10.dp) } diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/room/RoomPage.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/room/RoomPage.kt index 549e131..b75eade 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/room/RoomPage.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/room/RoomPage.kt @@ -27,11 +27,13 @@ import app.cash.paging.compose.LazyPagingItems import app.cash.paging.compose.collectAsLazyPagingItems import app.cash.paging.compose.itemContentType import app.cash.paging.compose.itemKey +import com.storyteller_f.a.app.LocalAppNav import com.storyteller_f.a.app.bus import com.storyteller_f.a.app.client import com.storyteller_f.a.app.clientWs import com.storyteller_f.a.app.common.* import com.storyteller_f.a.app.common.viewModel +import com.storyteller_f.a.app.community.CommunityRefCell import com.storyteller_f.a.app.compontents.* import com.storyteller_f.a.app.globalDialogState import com.storyteller_f.a.app.search.CustomSearchBar @@ -48,6 +50,7 @@ import com.storyteller_f.shared.obj.RoomFrame import com.storyteller_f.shared.obj.ServerResponse import com.storyteller_f.shared.type.ObjectType import com.storyteller_f.shared.type.PrimaryKey +import com.storyteller_f.shared.type.toPrimaryKeyOrNull import io.github.aakira.napier.Napier import io.ktor.client.* import io.ktor.client.plugins.websocket.* @@ -64,14 +67,14 @@ import org.jetbrains.compose.resources.stringResource data class OnRoomJoined(val id: PrimaryKey) @OptIn(ExperimentalPagingApi::class) -class RoomTopicsViewModel(roomId: PrimaryKey) : PagingViewModel({ +class TopicsViewModel(id: PrimaryKey, val type: ObjectType) : PagingViewModel({ CustomQueryPagingSource( select = select(all()), - collectionName = "topics$roomId", + collectionName = "topics$id", queryProvider = { where { if (it != null) { - "id" lessThan it.toLong() + "id" lessThan it } else { Expression.intValue(0) equalTo Expression.intValue(0) } @@ -85,12 +88,17 @@ class RoomTopicsViewModel(roomId: PrimaryKey) : PagingViewModel - processEncryptedTopic(client.getRoomTopics(roomId, loadKey, 20)) +}, TopicsRemoteMediator("topics$id") { loadKey -> + val info = when (type) { + ObjectType.ROOM -> client.getRoomTopics(id, loadKey, 20) + ObjectType.COMMUNITY -> client.getCommunityTopics(id, loadKey, 20) + else -> client.getTopicTopics(id, loadKey, 20) + } + info.copy(processEncryptedTopic(info.data)) }) @OptIn(ExperimentalPagingApi::class) -class RoomTopicsRemoteMediator( +class TopicsRemoteMediator( private val collectionName: String, val networkService: suspend (PrimaryKey?) -> ServerResponse ) : @@ -186,9 +194,9 @@ class RoomViewModel(private val requestInfo: suspend HttpClient.() -> RoomInfo) } @Composable -fun RoomPage(roomId: PrimaryKey, onLogin: () -> Unit, onClick: (PrimaryKey, ObjectType) -> Unit) { - val viewModel = viewModel(RoomTopicsViewModel::class, keys = listOf("room-topics", roomId)) { - RoomTopicsViewModel(roomId) +fun RoomPage(roomId: PrimaryKey) { + val viewModel = viewModel(TopicsViewModel::class, keys = listOf("room-topics", roomId)) { + TopicsViewModel(roomId, ObjectType.ROOM) } val room = viewModel(RoomViewModel::class, keys = listOf("room", roomId)) { RoomViewModel(roomId) @@ -209,11 +217,11 @@ fun RoomPage(roomId: PrimaryKey, onLogin: () -> Unit, onClick: (PrimaryKey, Obje Scaffold(snackbarHost = { SnackbarHost(snackBarHost) }) { - Column(modifier = Modifier.padding(it).consumeWindowInsets(WindowInsets.statusBars)) { - CustomSearchBar(onLogin) { + Column(modifier = Modifier.navigationBarsPadding()) { + CustomSearchBar { RoomIcon(roomInfo, size = 40.dp) } - RoomPageInternal(lazyListState, items, onClick) + RoomPageInternal(lazyListState, items) val scope = rememberCoroutineScope() RoomInputGroup(roomId, roomInfo, { scope.launch { @@ -223,7 +231,7 @@ fun RoomPage(roomId: PrimaryKey, onLogin: () -> Unit, onClick: (PrimaryKey, Obje scope.launch { lazyListState.animateScrollToItem(0) } - }, onClick) + }) } } } @@ -231,8 +239,7 @@ fun RoomPage(roomId: PrimaryKey, onLogin: () -> Unit, onClick: (PrimaryKey, Obje @Composable private fun ColumnScope.RoomPageInternal( lazyListState: LazyListState, - items: LazyPagingItems, - onClick: (PrimaryKey, ObjectType) -> Unit + items: LazyPagingItems ) { LazyColumn( state = lazyListState, @@ -257,8 +264,7 @@ private fun ColumnScope.RoomPageInternal( TopicCell( current, false, - next?.author != current?.author, - onClick = onClick + next?.author != current?.author ) } } @@ -276,8 +282,16 @@ class RoomKeysViewModel(private val id: PrimaryKey, private: Boolean) : override suspend fun loadInternal() { handler.request { runCatching { - val list = client.requestRoomKeys(id) - list.data + val result = mutableListOf>() + var last: PrimaryKey? = null + while (true) { + val list = client.requestRoomKeys(id, last, 100) + result.addAll(list.data) + val nextKey = list.pagination?.nextPageToken?.toPrimaryKeyOrNull() + if (nextKey == null) break + last = nextKey + } + result } } } @@ -288,8 +302,7 @@ private fun RoomInputGroup( roomId: PrimaryKey, roomInfo: RoomInfo?, notifyError: (String) -> Unit, - scrollToNew: () -> Unit, - onClick: (PrimaryKey, ObjectType) -> Unit + scrollToNew: () -> Unit ) { var input by remember { mutableStateOf("") @@ -320,10 +333,11 @@ private fun RoomInputGroup( ) } + val appNav = LocalAppNav.current CustomAlertDialog(alertDialogState, { alertDialogState = null }) { - onClick(roomId, ObjectType.ROOM) + appNav.goto(roomId, ObjectType.ROOM) } } @@ -428,6 +442,8 @@ fun InputGroupInternal( @Composable fun RoomDialogInternal(roomInfo: RoomInfo) { + val appNav = LocalAppNav.current + val onClick = appNav::goto DialogContainer { Row( modifier = Modifier.fillMaxWidth() @@ -442,6 +458,12 @@ fun RoomDialogInternal(roomInfo: RoomInfo) { } } + roomInfo.communityId?.let { + CommunityRefCell(it) { + onClick(it, ObjectType.COMMUNITY) + } + } + Column { ButtonNav(Icons.Default.Settings, stringResource(Res.string.settings)) ButtonNav(Icons.Default.Close, stringResource(Res.string.close)) @@ -453,6 +475,12 @@ fun RoomDialogInternal(roomInfo: RoomInfo) { ButtonNav(Icons.Default.AddHome, "Join Room") { scope.launch { globalDialogState.use { + val communityId = roomInfo.communityId + if (communityId != null) { + if (!client.getCommunityInfo(communityId).isJoined) { + throw Exception("you should join community first.") + } + } client.joinRoom(roomInfo.id) bus.send(OnRoomJoined(roomInfo.id)) } @@ -465,7 +493,11 @@ fun RoomDialogInternal(roomInfo: RoomInfo) { @OptIn(ExperimentalMaterial3Api::class) @Composable -fun RoomDialog(showDialog: Boolean, roomInfo: RoomInfo?, dismiss: () -> Unit) { +fun RoomDialog( + showDialog: Boolean, + roomInfo: RoomInfo?, + dismiss: () -> Unit +) { if (roomInfo != null && showDialog) { BasicAlertDialog({ dismiss() diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/room/RoomRefCell.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/room/RoomRefCell.kt index 0f2a118..1e6a7bc 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/room/RoomRefCell.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/room/RoomRefCell.kt @@ -53,6 +53,6 @@ private fun RoomRefCellInternal(viewModel: RoomViewModel, onClick: (PrimaryKey) } .padding(10.dp) ) { - RoomCell(it, true, onClick) + RoomCell(it, true) } } diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/search/CustomSearchBar.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/search/CustomSearchBar.kt index 10e45e6..c429ec5 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/search/CustomSearchBar.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/search/CustomSearchBar.kt @@ -8,24 +8,40 @@ import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import app.cash.paging.compose.collectAsLazyPagingItems +import com.storyteller_f.a.app.LocalAppNav +import com.storyteller_f.a.app.client +import com.storyteller_f.a.app.common.APagingData +import com.storyteller_f.a.app.common.PagingViewModel +import com.storyteller_f.a.app.common.SimplePagingSource +import com.storyteller_f.a.app.common.serviceCatching +import com.storyteller_f.a.app.common.viewModel import com.storyteller_f.a.app.compontents.UserIcon +import com.storyteller_f.a.app.world.TopicList import com.storyteller_f.a.client_lib.LoginViewModel +import com.storyteller_f.a.client_lib.searchTopics +import com.storyteller_f.shared.model.TopicInfo import com.storyteller_f.shared.model.UserInfo +import com.storyteller_f.shared.type.PrimaryKey +import com.storyteller_f.shared.type.toPrimaryKeyOrNull @OptIn(ExperimentalMaterial3Api::class) @Composable -fun CustomSearchBar(onLogin: () -> Unit, leadingIcon: @Composable () -> Unit) { +fun CustomSearchBar(leadingIcon: @Composable () -> Unit) { + val appNav = LocalAppNav.current var query by remember { mutableStateOf("") } + var searchQuery by remember { + mutableStateOf("") + } var active by remember { mutableStateOf(false) } + val onActiveChange = { newValue: Boolean -> + active = newValue + } Box(modifier = Modifier.fillMaxWidth()) { - val onActiveChange = { newValue: Boolean -> - active = newValue - } - SearchBar( inputField = { SearchBarDefaults.InputField( @@ -34,31 +50,45 @@ fun CustomSearchBar(onLogin: () -> Unit, leadingIcon: @Composable () -> Unit) { query = it }, onSearch = { + searchQuery = it }, expanded = active, onExpandedChange = onActiveChange, - enabled = true, - placeholder = null, leadingIcon = { leadingIcon() }, trailingIcon = { val userInfo by LoginViewModel.user.collectAsState() - UserIcon(userInfo, 40.dp, onLogin) + UserIcon(userInfo, 40.dp, appNav::gotoLogin) }, - interactionSource = null, ) }, expanded = active, onExpandedChange = onActiveChange, modifier = Modifier.align(Alignment.Center), - shape = SearchBarDefaults.inputFieldShape, - colors = SearchBarDefaults.colors(), - tonalElevation = SearchBarDefaults.TonalElevation, - shadowElevation = SearchBarDefaults.ShadowElevation, - windowInsets = SearchBarDefaults.windowInsets, content = { + if (searchQuery.isNotBlank()) { + val viewModel = viewModel(TopicSearchViewModel::class, keys = listOf("topic", query)) { + TopicSearchViewModel(query.split(" ")) + } + val topics = viewModel.flow.collectAsLazyPagingItems() + TopicList(topics) + } else { + Box { + Text("input word to search topics") + } + } }, ) } } + +class TopicSearchViewModel(word: List) : PagingViewModel({ + SimplePagingSource { + serviceCatching { + client.searchTopics(it, 10, word) + }.map { + APagingData(it.data, it.pagination?.nextPageToken?.toPrimaryKeyOrNull()) + } + } +}) diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/topic/RefRoute.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/topic/RefRoute.kt index 58743ec..8eb7154 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/topic/RefRoute.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/topic/RefRoute.kt @@ -1,22 +1,23 @@ package com.storyteller_f.a.app.topic import androidx.compose.runtime.Composable +import com.storyteller_f.a.app.AppNav import com.storyteller_f.a.app.community.CommunityRefCell import com.storyteller_f.a.app.room.RoomRefCell import com.storyteller_f.a.app.user.UserRefCell import com.storyteller_f.shared.type.ObjectType -import com.storyteller_f.shared.type.PrimaryKey +import com.storyteller_f.shared.type.toPrimaryKeyOrNull class TopicRoute( val pattern: String, - val builder: @Composable (Map, onClick: (PrimaryKey, ObjectType) -> Unit) -> Unit + val builder: @Composable (Map, appNav: AppNav) -> Unit ) { companion object { fun parseRefUri( string: String ): Pair<@Composable (( Map, - (PrimaryKey, ObjectType) -> Unit + AppNav ) -> Unit)?, MutableMap> { val target = string.split("/") val map = mutableMapOf() @@ -47,59 +48,59 @@ class TopicRoute( } val ROUTE = mutableListOf( - TopicRoute("/topic/{id}") { params, onClick -> - params["id"]?.toULongOrNull()?.let { + TopicRoute("/topic/{id}") { params, appNav -> + params["id"]?.toPrimaryKeyOrNull()?.let { TopicRefCell(it) { - onClick(it, ObjectType.TOPIC) + appNav.goto(it, ObjectType.TOPIC) } } }, - TopicRoute("/topic/a/{aid}") { params, onClick -> + TopicRoute("/topic/a/{aid}") { params, appNav -> params["aid"]?.let { TopicRefCell(it) { - onClick(it, ObjectType.TOPIC) + appNav.goto(it, ObjectType.TOPIC) } } }, - TopicRoute("/room/{id}") { params, onClick -> - params["id"]?.toULongOrNull()?.let { + TopicRoute("/room/{id}") { params, appNav -> + params["id"]?.toPrimaryKeyOrNull()?.let { RoomRefCell(it) { - onClick(it, ObjectType.ROOM) + appNav.goto(it, ObjectType.ROOM) } } }, - TopicRoute("/room/a/{aid}") { params, onClick -> + TopicRoute("/room/a/{aid}") { params, appNav -> params["aid"]?.let { RoomRefCell(it) { - onClick(it, ObjectType.ROOM) + appNav.goto(it, ObjectType.ROOM) } } }, - TopicRoute("/community/{id}") { params, onClick -> - params["id"]?.toULongOrNull()?.let { + TopicRoute("/community/{id}") { params, appNav -> + params["id"]?.toPrimaryKeyOrNull()?.let { CommunityRefCell(it) { - onClick(it, ObjectType.COMMUNITY) + appNav.goto(it, ObjectType.COMMUNITY) } } }, - TopicRoute("/community/a/{aid}") { params, onClick -> + TopicRoute("/community/a/{aid}") { params, appNav -> params["aid"]?.let { CommunityRefCell(it) { - onClick(it, ObjectType.COMMUNITY) + appNav.goto(it, ObjectType.COMMUNITY) } } }, - TopicRoute("/user/{id}") { params, onClick -> - params["id"]?.toULongOrNull()?.let { + TopicRoute("/user/{id}") { params, appNav -> + params["id"]?.toPrimaryKeyOrNull()?.let { UserRefCell(it) { - onClick(it, ObjectType.USER) + appNav.goto(it, ObjectType.USER) } } }, - TopicRoute("/user/a/{aid}") { params, onClick -> + TopicRoute("/user/a/{aid}") { params, appNav -> params["aid"]?.let { UserRefCell(it) { - onClick(it, ObjectType.USER) + appNav.goto(it, ObjectType.USER) } } } diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/topic/TopicCell.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/topic/TopicCell.kt index 5853b68..8157abe 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/topic/TopicCell.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/topic/TopicCell.kt @@ -24,6 +24,7 @@ import com.mikepenz.markdown.compose.components.markdownComponents import com.mikepenz.markdown.compose.elements.MarkdownCodeFence import com.mikepenz.markdown.m3.markdownColor import com.mikepenz.markdown.m3.markdownTypography +import com.storyteller_f.a.app.LocalAppNav import com.storyteller_f.a.app.common.viewModel import com.storyteller_f.a.app.compontents.ReactionRow import com.storyteller_f.a.app.compontents.TextUnitToPx @@ -34,7 +35,6 @@ import com.storyteller_f.shared.model.TopicContent import com.storyteller_f.shared.model.TopicInfo import com.storyteller_f.shared.model.UserInfo import com.storyteller_f.shared.type.ObjectType -import com.storyteller_f.shared.type.PrimaryKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.intellij.markdown.MarkdownTokenTypes @@ -45,8 +45,7 @@ import org.intellij.markdown.ast.getTextInNode fun TopicCell( topicInfo: TopicInfo?, contentAlignAvatar: Boolean = true, - showAvatar: Boolean = true, - onClick: (PrimaryKey, ObjectType) -> Unit = { _, _ -> } + showAvatar: Boolean = true ) { if (topicInfo != null) { val author = topicInfo.author @@ -54,7 +53,7 @@ fun TopicCell( UserViewModel(author) } val authorInfo by authorViewModel.handler.data.collectAsState() - TopicCellInternal(topicInfo, showAvatar, authorInfo, contentAlignAvatar, onClick) + TopicCellInternal(topicInfo, showAvatar, authorInfo, contentAlignAvatar) } } @@ -63,9 +62,10 @@ fun TopicCellInternal( topicInfo: TopicInfo, showAvatar: Boolean, authorInfo: UserInfo?, - contentAlignAvatar: Boolean, - onClick: (PrimaryKey, ObjectType) -> Unit = { _, _ -> } + contentAlignAvatar: Boolean ) { + val appNav = LocalAppNav.current + val onClick = appNav::goto Column( verticalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -87,8 +87,7 @@ fun TopicCellInternal( topicInfo.content, onClick = { onClick(topicInfo.id, ObjectType.TOPIC) - }, - onFenceClick = onClick + } ) ReactionRow() } @@ -96,7 +95,7 @@ fun TopicCellInternal( } @Composable -fun CustomCodeFence(modal: MarkdownComponentModel, content: String, onClick: (PrimaryKey, ObjectType) -> Unit) { +fun CustomCodeFence(modal: MarkdownComponentModel, content: String) { val children = modal.node.children val langOffset = children.indexOfFirst { it.type == MarkdownTokenTypes.FENCE_LANG @@ -104,7 +103,7 @@ fun CustomCodeFence(modal: MarkdownComponentModel, content: String, onClick: (Pr val lang = children.getOrNull(langOffset)?.getTextInNode(content) (when { listOf("com.storyteller_f.a", "c.s.a", "csa").contains(lang) -> { - RefBlock(children, langOffset, content, onClick) + RefBlock(children, langOffset, content) } lang == "math" -> { @@ -119,12 +118,12 @@ fun CustomCodeFence(modal: MarkdownComponentModel, content: String, onClick: (Pr private fun RefBlock( children: List, langOffset: Int, - content: String, - onClick: (PrimaryKey, ObjectType) -> Unit + content: String ) { + val appNav = LocalAppNav.current val textInNode = readFenceContent(children, langOffset, content) TopicRoute.parseRefUri(textInNode).let { - it.first?.let { it1 -> it1(it.second, onClick) } + it.first?.let { it1 -> it1(it.second, appNav) } } } @@ -178,22 +177,19 @@ private fun readFenceContent( fun TopicContentField( content1: TopicContent?, modifier: Modifier = Modifier, - onClick: (() -> Unit)? = null, - onFenceClick: (PrimaryKey, ObjectType) -> Unit = { _, _ -> } + onClick: (() -> Unit)? = null ) { if (content1 is TopicContent.Plain) { Markdown( content1.plain, - modifier = modifier.clickable(onClick != null) { + modifier = modifier.fillMaxWidth().clickable(onClick != null) { onClick?.invoke() }, colors = markdownColor(), typography = markdownTypography(), imageTransformer = Coil3ImageTransformerImpl, components = markdownComponents(codeFence = { model -> - CustomCodeFence(model, content1.plain) { id, type -> - onFenceClick(id, type) - } + CustomCodeFence(model, content1.plain) }) ) } else if (content1 is TopicContent.DecryptFailed) { diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/topic/TopicComposePage.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/topic/TopicComposePage.kt index 9086015..2c7618b 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/topic/TopicComposePage.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/topic/TopicComposePage.kt @@ -1,10 +1,12 @@ package com.storyteller_f.a.app.topic import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check @@ -13,12 +15,18 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.storyteller_f.a.app.client +import com.storyteller_f.a.app.common.getOrCreateCollection import com.storyteller_f.a.app.globalDialogState import com.storyteller_f.a.client_lib.createNewTopic import com.storyteller_f.shared.model.TopicContent +import com.storyteller_f.shared.model.TopicInfo import com.storyteller_f.shared.type.ObjectType import com.storyteller_f.shared.type.PrimaryKey +import io.ktor.client.call.body +import kotbase.MutableDocument import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable @@ -32,57 +40,94 @@ fun TopicComposePage(objectType: ObjectType, objectId: PrimaryKey, backPrePage: var selected by remember { mutableIntStateOf(0) } - val coroutineScope = rememberCoroutineScope() - val tabs = listOf("Edit", "Preview") Scaffold(topBar = { TopAppBar({ }, actions = { - IconButton({ - val c = input.trim() - if (c.isNotEmpty()) { - coroutineScope.launch { - try { - client.createNewTopic(objectType, objectId, input) - backPrePage() - } catch (e: Exception) { - globalDialogState.showError(e) - } - } - } - }) { - Icon(imageVector = Icons.Default.Check, "submit") - } + TopicComposeSubmitButton(input, objectType, objectId, backPrePage) }) }) { values -> Column(modifier = Modifier.padding(values)) { - PrimaryTabRow(selected) { - tabs.forEachIndexed { i, e -> - Tab(selected = selected == i, onClick = { - selected = i - coroutineScope.launch { - pagerState.scrollToPage(i) - } - }) { - Text(text = e, modifier = Modifier.padding(vertical = 12.dp)) - } + TopicComposeInternal(selected, pagerState, listOf("Edit", "Preview"), input, { + input = it + }) { + selected = it + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopicComposeInternal( + selected: Int, + pagerState: PagerState, + tabs: List, + input: String, + updateInput: (String) -> Unit, + updateSelected: (Int) -> Unit +) { + val coroutineScope = rememberCoroutineScope() + + PrimaryTabRow(selected) { + tabs.forEachIndexed { i, e -> + Tab(selected = selected == i, onClick = { + updateSelected(i) + coroutineScope.launch { + pagerState.scrollToPage(i) } + }) { + Text(text = e, modifier = Modifier.padding(vertical = 12.dp)) + } + } + } + HorizontalPager(pagerState, key = tabs::get) { index -> + if (index == 0) { + EditTopicPage(input) { + updateInput(it) } - HorizontalPager(pagerState, key = tabs::get) { index -> - if (index == 0) { - EditTopicPage(input) { - input = it - } - } else { - PreviewTopicPage(input) + } else { + PreviewTopicPage(input) + } + } +} + +@Composable +private fun TopicComposeSubmitButton( + input: String, + objectType: ObjectType, + objectId: PrimaryKey, + backPrePage: () -> Unit +) { + val coroutineScope = rememberCoroutineScope() + + IconButton({ + val c = input.trim() + if (c.isNotEmpty()) { + coroutineScope.launch { + try { + val info = client.createNewTopic(objectType, objectId, input).body() + getOrCreateCollection("topics${info.parentId}").save( + MutableDocument( + info.id.toString(), + Json.encodeToString(info) + ) + ) + backPrePage() + } catch (e: Exception) { + globalDialogState.showError(e) } } } + }) { + Icon(imageVector = Icons.Default.Check, "submit") } } @Composable fun PreviewTopicPage(input: String) { - TopicContentField(TopicContent.Plain(input)) + Box(modifier = Modifier.fillMaxSize()) { + TopicContentField(TopicContent.Plain(input)) + } } @Composable diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/topic/TopicPage.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/topic/TopicPage.kt index 65fdd7e..2efc5e2 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/topic/TopicPage.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/topic/TopicPage.kt @@ -9,8 +9,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.lazy.LazyColumn @@ -25,10 +27,14 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.* import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.paging.ExperimentalPagingApi import app.cash.paging.compose.collectAsLazyPagingItems +import com.dokar.sonner.Toaster +import com.dokar.sonner.rememberToasterState +import com.storyteller_f.a.app.LocalAppNav import com.storyteller_f.a.app.client import com.storyteller_f.a.app.common.* import com.storyteller_f.a.app.common.viewModel @@ -38,29 +44,35 @@ import com.storyteller_f.a.app.compontents.ReactionRow import com.storyteller_f.a.app.globalDialogState import com.storyteller_f.a.app.room.InputGroupInternal import com.storyteller_f.a.app.room.RoomSendButton +import com.storyteller_f.a.app.room.TopicsViewModel import com.storyteller_f.a.app.search.CustomSearchBar import com.storyteller_f.a.client_lib.* import com.storyteller_f.shared.decrypt import com.storyteller_f.shared.getDerPrivateKey import com.storyteller_f.shared.model.TopicContent import com.storyteller_f.shared.model.TopicInfo -import com.storyteller_f.shared.obj.ServerResponse import com.storyteller_f.shared.type.ObjectType import com.storyteller_f.shared.type.PrimaryKey +import com.storyteller_f.shared.type.toPrimaryKeyOrNull import io.ktor.client.* +import io.ktor.client.call.body +import kotbase.MutableDocument import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource +import kotlin.time.Duration.Companion.seconds @Composable -fun TopicPage(topicId: PrimaryKey, onLogin: () -> Unit, onClick: (PrimaryKey, ObjectType) -> Unit) { +fun TopicPage(topicId: PrimaryKey) { val viewModel = viewModel(TopicViewModel::class, keys = listOf("topic", topicId)) { TopicViewModel(topicId) } val topic by viewModel.handler.data.collectAsState() - val topicsViewModel = viewModel(TopicNestedViewModel::class, keys = listOf("topic-topics", topicId)) { - TopicNestedViewModel(topicId) + val topicsViewModel = viewModel(TopicsViewModel::class, keys = listOf("topic-topics", topicId)) { + TopicsViewModel(topicId, ObjectType.TOPIC) } val topics = topicsViewModel.flow.collectAsLazyPagingItems() val snackBarHost = remember { @@ -73,7 +85,7 @@ fun TopicPage(topicId: PrimaryKey, onLogin: () -> Unit, onClick: (PrimaryKey, Ob var showDialog by remember { mutableStateOf(false) } - CustomSearchBar(onLogin) { + CustomSearchBar { Icon(Icons.Default.Topic, "topic", modifier = Modifier.clickable { showDialog = true }) @@ -90,24 +102,20 @@ fun TopicPage(topicId: PrimaryKey, onLogin: () -> Unit, onClick: (PrimaryKey, Ob verticalArrangement = Arrangement.spacedBy(12.dp) ) { item { - TopicContentField(it.content, onFenceClick = onClick) - } - - item { + TopicContentField(it.content) + Spacer(modifier = Modifier.height(12.dp)) ReactionRow() - } - - item { + Spacer(modifier = Modifier.height(12.dp)) HorizontalDivider() } nestedStateView(topics) { - TopicCell(it, onClick = onClick) + TopicCell(it) } } } } - topic?.let { it1 -> TopicInputGroup(it1, topicId, snackBarHost, onClick) } + topic?.let { it1 -> TopicInputGroup(it1, topicId) } } } } @@ -116,8 +124,6 @@ fun TopicPage(topicId: PrimaryKey, onLogin: () -> Unit, onClick: (PrimaryKey, Ob private fun TopicInputGroup( topic: TopicInfo, topicId: PrimaryKey, - snackBarHost: SnackbarHostState, - onClick: (PrimaryKey, ObjectType) -> Unit, ) { var input by remember { mutableStateOf("") @@ -125,12 +131,15 @@ private fun TopicInputGroup( var alertDialogState by remember { mutableStateOf(null) } + val toaster = rememberToasterState() + Toaster(toaster, alignment = Alignment.Center) val scope = rememberCoroutineScope() InputGroupInternal(input, { input = it }, sendButton = { if (topic.rootType == ObjectType.ROOM) { RoomSendButton(input) { + toaster.show("not yet support", duration = 1.seconds) } } else { IconButton({ @@ -141,8 +150,14 @@ private fun TopicInputGroup( } else { scope.launch { try { - client.createNewTopic(ObjectType.TOPIC, topicId, input) - snackBarHost.showSnackbar(getString(Res.string.success)) + val info = client.createNewTopic(ObjectType.TOPIC, topicId, input).body() + getOrCreateCollection("topics${info.parentId}").save( + MutableDocument( + info.id.toString(), + Json.encodeToString(info) + ) + ) + toaster.show(getString(Res.string.success), duration = 1.seconds) } catch (e: Exception) { globalDialogState.showError(e) } @@ -154,10 +169,11 @@ private fun TopicInputGroup( } }) + val appNav = LocalAppNav.current CustomAlertDialog(alertDialogState, { alertDialogState = null }) { - onClick(topic.rootId, topic.rootType) + appNav.goto(topic.rootId, topic.rootType) } } @@ -177,7 +193,8 @@ class TopicViewModel(private val requestInfo: suspend HttpClient.() -> TopicInfo override suspend fun loadInternal() { handler.request { serviceCatching { - requestInfo(client) + val info = requestInfo(client) + processEncryptedTopic(listOf(info)).first() } } } @@ -187,19 +204,20 @@ class TopicViewModel(private val requestInfo: suspend HttpClient.() -> TopicInfo class TopicNestedViewModel(topicId: PrimaryKey) : PagingViewModel({ SimplePagingSource { serviceCatching { - processEncryptedTopic(client.getTopicTopics(topicId, it, 10)) + val info = client.getTopicTopics(topicId, it, 10) + info.copy(processEncryptedTopic(info.data)) }.map { - APagingData(it.data, it.pagination?.nextPageToken?.toULongOrNull()) + APagingData(it.data, it.pagination?.nextPageToken?.toPrimaryKeyOrNull()) } } }) @OptIn(ExperimentalStdlibApi::class) -suspend fun processEncryptedTopic(info: ServerResponse): ServerResponse { +suspend fun processEncryptedTopic(info: List): List { val value = LoginViewModel.state.value val uid = LoginViewModel.user.value?.id val key = if (value is ClientSession.LoginSuccess) getDerPrivateKey(value.privateKey) else null - return info.copy(info.data.map { topicInfo -> + return info.map { topicInfo -> val content = topicInfo.content if (content !is TopicContent.Encrypted || uid == null || key == null) { topicInfo @@ -207,7 +225,7 @@ suspend fun processEncryptedTopic(info: ServerResponse): ServerRespon val s = content.encryptedKey[uid] topicInfo.copy( content = if (s != null) { - runCatching { + runCatching { decrypt( key, content.encrypted.hexToByteArray(), @@ -223,5 +241,5 @@ suspend fun processEncryptedTopic(info: ServerResponse): ServerRespon } ) } - }) + } } diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/world/WorldPage.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/world/WorldPage.kt index 6e74a7c..20c4214 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/world/WorldPage.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/world/WorldPage.kt @@ -25,16 +25,16 @@ import com.storyteller_f.a.app.common.viewModel import com.storyteller_f.a.app.topic.TopicCell import com.storyteller_f.a.client_lib.getWorldTopics import com.storyteller_f.shared.model.TopicInfo -import com.storyteller_f.shared.type.ObjectType import com.storyteller_f.shared.type.PrimaryKey +import com.storyteller_f.shared.type.toPrimaryKeyOrNull @Composable -fun WorldPage(onClick: (PrimaryKey, ObjectType) -> Unit) { +fun WorldPage() { val viewModel = viewModel(WorldViewModel::class) { WorldViewModel() } val items = viewModel.flow.collectAsLazyPagingItems() - TopicList(items, onClick) + TopicList(items) } @OptIn(ExperimentalPagingApi::class) @@ -43,15 +43,14 @@ class WorldViewModel : PagingViewModel({ serviceCatching { client.getWorldTopics(it, 10) }.map { - APagingData(it.data, it.pagination?.nextPageToken?.toULongOrNull()) + APagingData(it.data, it.pagination?.nextPageToken?.toPrimaryKeyOrNull()) } } }) @Composable fun TopicList( - items: LazyPagingItems, - onClick: (PrimaryKey, ObjectType) -> Unit + items: LazyPagingItems ) { StateView(items) { LazyColumn( @@ -63,9 +62,11 @@ fun TopicList( key = items.itemKey(), contentType = items.itemContentType() ) { index -> - TopicCell(items[index], onClick = onClick) + TopicCell(items[index]) Spacer(modifier = Modifier.height(20.dp)) - HorizontalDivider() + if (index != items.itemCount - 1) { + HorizontalDivider() + } } } } diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 9fbf7cc..3bfef19 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -297,7 +297,7 @@ exceptions: - 'Throwable' allowedExceptionNameRegex: '_|(ignore|expected).*' TooGenericExceptionThrown: - active: true + active: false exceptionNames: - 'Error' - 'Exception' diff --git a/dev.env b/dev.env index 86333aa..723e58f 100644 --- a/dev.env +++ b/dev.env @@ -22,7 +22,7 @@ ELASTIC_NAME=elastic ELASTIC_PASSWORD=elastic MEDIA_SERVICE=minio -MINIO_URL=http://192.168.2.104:9000 +MINIO_URL=http://192.168.2.14:9000 MINIO_PORT=9000 MINIO_PORT_CONSOLE=9001 MINIO_NAME=minioadmin @@ -43,11 +43,11 @@ BUNKER_PORT=8080 BUNKER_PORT_SECRET=8443 SERVER_PORT=8811 -SERVER_URL=http://192.168.2.104:8811/ -WS_SERVER_URL=ws://192.168.2.104:8811/ +SERVER_URL=http://192.168.2.14:8811/ +WS_SERVER_URL=ws://192.168.2.14:8811/ PRESET_ENABLE=false PRESET_WORKING_DIR='../deploy' -PRESET_SCRIPT='sh ./flush-database-singleton.sh ../cli/build/install/cli/bin/cli ./preset_data' +PRESET_SCRIPT='./flush-database-singleton.sh ../cli/build/install/cli/bin/cli ./preset_data' PRESET_ENCRYPTED_URI= PRESET_ENCRYPTED_PASSWORD= diff --git a/dev.win.env b/dev.win.env index 0b0ad0d..e81c940 100644 --- a/dev.win.env +++ b/dev.win.env @@ -48,6 +48,6 @@ WS_SERVER_URL=ws://192.168.31.44:8811 PRESET_ENABLE=false PRESET_WORKING_DIR='../deploy' -PRESET_SCRIPT='sh ./flush-database-singleton.sh ../cli/build/install/cli/bin/cli ./preset_data' +PRESET_SCRIPT='./flush-database-singleton.sh ../cli/build/install/cli/bin/cli ./preset_data' PRESET_ENCRYPTED_URI= PRESET_ENCRYPTED_PASSWORD= diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 154b7d8..e01e7ec 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ bcprovJdk18on = "1.78.1" coil = "3.0.0-rc01" compose-plugin = "1.7.0" couchbaseLite = "3.1.3-1.1.0" +desugar_jdk_libs = "2.1.3" detektVersion = "1.23.7" emoji-reader = "2.0.6" h2 = "2.2.224" @@ -51,6 +52,8 @@ espresso-core = "3.6.1" androidx-test-ext-junit = "1.2.1" [libraries] +desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } +sonner = { module = "io.github.dokar3:sonner", version = "0.3.8" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } androidx-ui-test-junit4-android = { module = "androidx.compose.ui:ui-test-junit4-android", version.ref = "uiTestJunit4Android" } diff --git a/scripts/service_scripts/start-service-on-remote.sh b/scripts/service_scripts/start-service-on-remote.sh index 95a79b3..31b0278 100755 --- a/scripts/service_scripts/start-service-on-remote.sh +++ b/scripts/service_scripts/start-service-on-remote.sh @@ -24,11 +24,19 @@ fi ./scripts/tool_scripts/exec-until-success.sh ssh -i "$REMOTE_CERT_FILE" -p 422 "$PUSH_TO_REMOTE_URI" "mkdir -p a-server" +sleep 2 + echo "put $FILE ./a-server/$FLAVOR.image.tar" | sftp -i "$REMOTE_CERT_FILE" -P 422 "$PUSH_TO_REMOTE_URI" +sleep 2 + md=$(md5sum "$FILE") ssh -i "$REMOTE_CERT_FILE" -p 422 "$PUSH_TO_REMOTE_URI" "echo ""$md" "./$FLAVOR.image.tar"" | md5sum -c -" +sleep 2 + ./scripts/tool_scripts/exec-until-success.sh ssh -i "$REMOTE_CERT_FILE" -p 422 "$PUSH_TO_REMOTE_URI" "sudo -s mv ./a-server/* /tmp/A" +sleep 2 + ./scripts/tool_scripts/exec-until-success.sh ssh -i "$REMOTE_CERT_FILE" -p 422 "$PUSH_TO_REMOTE_URI" "$REMOTE_COMMAND" \ No newline at end of file diff --git a/server/src/main/kotlin/com/storyteller_f/a/server/Application.kt b/server/src/main/kotlin/com/storyteller_f/a/server/Application.kt index 4a8b57f..011437d 100644 --- a/server/src/main/kotlin/com/storyteller_f/a/server/Application.kt +++ b/server/src/main/kotlin/com/storyteller_f/a/server/Application.kt @@ -120,14 +120,15 @@ fun Application.module() { cookie.maxAgeInSeconds = 3600 } } - if (backend.config.isProd) + if (backend.config.isProd) { install(RateLimit) { global { - rateLimiter(limit = 20, refillPeriod = 1.seconds) + rateLimiter(limit = 10, refillPeriod = 1.seconds) requestKey { call -> call.getRateLimitKey() } } } + } configureAuth(backend) } diff --git a/server/src/main/kotlin/com/storyteller_f/a/server/ProtectedContent.kt b/server/src/main/kotlin/com/storyteller_f/a/server/ProtectedContent.kt index 1ae13e1..e04cb07 100644 --- a/server/src/main/kotlin/com/storyteller_f/a/server/ProtectedContent.kt +++ b/server/src/main/kotlin/com/storyteller_f/a/server/ProtectedContent.kt @@ -9,6 +9,7 @@ import com.storyteller_f.shared.model.CommunityInfo import com.storyteller_f.shared.model.RoomInfo import com.storyteller_f.shared.obj.TopicSnapshotPack import com.storyteller_f.shared.type.PrimaryKey +import com.storyteller_f.types.PaginationResult import io.ktor.server.routing.* import io.ktor.server.websocket.* @@ -53,7 +54,7 @@ private fun Route.bindProtectedCommunityRoute(backend: Backend) { route("/community") { get("/joined") { usePrincipal { - pagination({ + pagination(PrimaryKey::class, { it.id.toString() }) { pre, next, size -> searchJoinedCommunities(it, backend, pre, next, size) @@ -74,8 +75,8 @@ private fun Route.bindProtectedRoomRoute(backend: Backend) { route("/room") { get("/joined") { usePrincipal { - pagination({ - "" + pagination(PrimaryKey::class, { + it.id.toString() }) { pre, next, size -> searchJoinedRooms(it, backend = backend, pre, next, size) } @@ -83,17 +84,17 @@ private fun Route.bindProtectedRoomRoute(backend: Backend) { } post("/{id}/join") { usePrincipal { id -> - checkParameter("id") { + checkParameter("id") { joinRoom(it, id) } } } get("/{id}/pub-keys") { usePrincipal { id -> - pagination, PrimaryKey>({ + pagination, PrimaryKey>(PrimaryKey::class, { it.first.toString() }) { pre, next, size -> - checkParameter>, Long>>("id") { + checkParameter>>("id") { getRoomPubKeys(it, id, pre, next, size) } } diff --git a/server/src/main/kotlin/com/storyteller_f/a/server/UnprotectedContent.kt b/server/src/main/kotlin/com/storyteller_f/a/server/UnprotectedContent.kt index a61fd58..c75c031 100644 --- a/server/src/main/kotlin/com/storyteller_f/a/server/UnprotectedContent.kt +++ b/server/src/main/kotlin/com/storyteller_f/a/server/UnprotectedContent.kt @@ -13,26 +13,27 @@ import com.storyteller_f.shared.model.TopicInfo import com.storyteller_f.shared.model.UserInfo import com.storyteller_f.shared.type.ObjectType import com.storyteller_f.shared.type.PrimaryKey +import com.storyteller_f.types.PaginationResult import io.ktor.server.routing.* fun Route.unProtectedContent(backend: Backend) { + bindWorldRoute(backend) + bindRoomRoute(backend) + bindCommunityRoute(backend) + bindTopicRoute(backend) + bindUserRoute(backend) +} + +private fun Route.bindWorldRoute(backend: Backend) { get("/world") { omitPrincipal { - pagination({ + pagination(PrimaryKey::class, { it.id.toString() }) { prePageToken, nextPageToken, size -> searchWorld(backend, prePageToken, nextPageToken, size) } } } - - bindRoomRoute(backend) - - bindCommunityRoute(backend) - - bindTopicRoute(backend) - - bindUserRoute(backend) } private fun Route.bindUserRoute(backend: Backend) { @@ -58,10 +59,10 @@ private fun Route.bindTopicRoute(backend: Backend) { route("/topic") { get("/{id}/topics") { usePrincipalOrNull { uid -> - pagination({ + pagination(PrimaryKey::class, { it.id.toString() }) { p, n, s -> - checkParameter, Long>>("id") { + checkParameter>("id") { getTopics(it, ObjectType.TOPIC, uid, backend, p, n, s) } } @@ -81,6 +82,18 @@ private fun Route.bindTopicRoute(backend: Backend) { verifySnapshot(backend) } } + + get("/search") { + usePrincipalOrNull { + pagination(PrimaryKey::class, { + it.id.toString() + }) { p, n, s -> + checkParameter, PaginationResult>("word") { + searchTopics(n, s, it, backend) + } + } + } + } } } @@ -88,10 +101,10 @@ private fun Route.bindCommunityRoute(backend: Backend) { route("/community") { get("/{id}/topics") { omitPrincipal { - pagination({ + pagination(PrimaryKey::class, { it.id.toString() }) { p, n, size -> - checkParameter, Long>>("id") { + checkParameter>("id") { getTopics( it, ObjectType.COMMUNITY, @@ -106,10 +119,10 @@ private fun Route.bindCommunityRoute(backend: Backend) { } get("/{id}/rooms") { usePrincipalOrNull { uid -> - pagination({ + pagination(PrimaryKey::class, { it.id.toString() }) { p, n, size -> - checkParameter, Long>>("id") { + checkParameter>("id") { searchRoomInCommunity(it, uid, backend, p, n, size) } } @@ -132,10 +145,10 @@ private fun Route.bindCommunityRoute(backend: Backend) { } get("/search") { omitPrincipal { - pagination({ + pagination(PrimaryKey::class, { it.id.toString() }) { p, n, s -> - checkQueryParameter, Long>>("word") { + checkQueryParameter>("word") { searchCommunities(it, backend, p, n, s) } } @@ -155,10 +168,10 @@ private fun Route.bindRoomRoute(backend: Backend) { } get("/{id}/topics") { usePrincipalOrNull { uid -> - pagination({ + pagination(PrimaryKey::class, { it.id.toString() }) { pre, next, size -> - checkParameter, Long>>("id") { + checkParameter>("id") { getTopics(it, ObjectType.ROOM, uid, backend, pre, next, size) } } @@ -174,10 +187,10 @@ private fun Route.bindRoomRoute(backend: Backend) { } get("/search") { usePrincipalOrNull { id -> - pagination({ + pagination(PrimaryKey::class, { it.id.toString() }) { p, n, size -> - checkQueryParameter, Long>>("word") { + checkQueryParameter>("word") { searchRooms(it, id, backend, p, n, size) } } diff --git a/server/src/main/kotlin/com/storyteller_f/a/server/Websocket.kt b/server/src/main/kotlin/com/storyteller_f/a/server/Websocket.kt index 1423bed..05d3660 100644 --- a/server/src/main/kotlin/com/storyteller_f/a/server/Websocket.kt +++ b/server/src/main/kotlin/com/storyteller_f/a/server/Websocket.kt @@ -13,10 +13,13 @@ import com.storyteller_f.shared.obj.NewTopic import com.storyteller_f.shared.obj.RoomFrame import com.storyteller_f.shared.type.ObjectType import com.storyteller_f.shared.type.PrimaryKey +import com.storyteller_f.shared.utils.mapResult +import com.storyteller_f.shared.utils.mapResultNotNull import com.storyteller_f.shared.utils.now import com.storyteller_f.tables.* import io.ktor.server.application.* import io.ktor.server.websocket.* +import io.ktor.util.logging.error import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch @@ -35,17 +38,23 @@ suspend fun DefaultWebSocketServerSession.webSocketContent(backend: Backend) { try { if (frame is RoomFrame.Message) { val newTopic = frame.newTopic - val newTopicInfo = addTopicAtRoom(newTopic, uid, backend = backend) - newTopicInfo.onSuccess { - val newFrame: RoomFrame = RoomFrame.NewTopicInfo(it) - sendSerialized(newFrame) + addTopicAtRoom(newTopic, uid, backend = backend).onSuccess { + if (it == null) { + val data: RoomFrame = RoomFrame.Error("not found") + sendSerialized(data) + } else { + val newFrame: RoomFrame = RoomFrame.NewTopicInfo(it) + sendSerialized(newFrame) + } }.onFailure { val message = it.message ?: "unknown error" + call.application.log.error(it) val data: RoomFrame = RoomFrame.Error(message) sendSerialized(data) } } } catch (e: Exception) { + call.application.log.error(e) val newFrame: RoomFrame = RoomFrame.Error(e.message.toString()) sendSerialized(newFrame) } @@ -72,18 +81,15 @@ private suspend fun addTopicAtRoom( newTopic: NewTopic, uid: PrimaryKey, backend: Backend -): Result { +): Result { return when (newTopic.parentType) { ObjectType.TOPIC -> { - val roomInfo = DatabaseFactory.queryNotNull({ + DatabaseFactory.queryNotNull({ rootId to rootType }) { Topic.findById(newTopic.parentId) - } - if (roomInfo != null && roomInfo.second == ObjectType.ROOM) { - addTopicIntoRoom(roomInfo, uid, newTopic, backend = backend) - } else { - Result.failure(ForbiddenException()) + }.mapResultNotNull { (id, type) -> + addTopicAtRoom(newTopic.copy(parentId = id, parentType = type), uid, backend) } } @@ -107,47 +113,48 @@ private suspend fun addTopicIntoRoom( uid: PrimaryKey, newTopic: NewTopic, backend: Backend -): Result { +): Result { val roomId = roomInfo.first - return if (isRoomJoined(roomId, uid)) { - val content = newTopic.content - val newId = SnowflakeFactory.nextId() - val topic = Topic( - uid, - roomId, - ObjectType.ROOM, - newTopic.parentId, - newTopic.parentType, - now(), - newId, - now() - ) + return isRoomJoined(roomId, uid).mapResult { bool -> + if (bool) { + val content = newTopic.content + val newId = SnowflakeFactory.nextId() + val topic = Topic( + uid, + roomId, + ObjectType.ROOM, + newTopic.parentId, + newTopic.parentType, + now(), + newId, + now() + ) + + checkRoomIsPrivate(roomId).mapResult { isPrivate -> + if (isPrivate) { + when { + content !is TopicContent.Encrypted -> Result.failure( + ForbiddenException("Private room only accept encrypted content.") + ) - when { - DatabaseFactory.dbQuery { - !checkRoomIsPrivate(roomId) - } -> { - if (content is TopicContent.Plain) { - Result.success(savePlainTopicContent(topic, content, backend = backend)) + isKeyVerified(roomId, content.encryptedKey).getOrNull() == true -> saveEncryptedTopicContent( + topic, + content.encryptedKey, + content.encrypted + ) + + else -> Result.failure(ForbiddenException("Private room only accept encrypted content.")) + } } else { - Result.failure(ForbiddenException("Public room only accept unencrypted content.")) + when (content) { + is TopicContent.Plain -> savePlainTopicContent(topic, content, backend = backend) + else -> Result.failure(ForbiddenException("Public room only accept unencrypted content.")) + } } } - - content is TopicContent.Encrypted && isKeyVerified(roomId, content.encryptedKey) -> { - Result.success( - saveEncryptedTopicContent( - topic, - content.encryptedKey, - content.encrypted - ) - ) - } - - else -> Result.failure(ForbiddenException("Private room only accept encrypted content.")) + } else { + Result.failure(ForbiddenException("Can't publish content before join room.")) } - } else { - Result.failure(ForbiddenException("Can't publish content before join room.")) } } @@ -155,7 +162,7 @@ private suspend fun savePlainTopicContent( topic: Topic, content: TopicContent.Plain, backend: Backend -): TopicInfo { +): Result { return DatabaseFactory.dbQuery { val newTopicId = Topic.new(topic) backend.topicDocumentService.saveDocument( @@ -187,16 +194,19 @@ suspend fun saveEncryptedTopicContent( this[EncryptedTopicKeys.encryptedAes] = ExposedBlob(encryptedAes[it]!!.hexToByteArray()) } - topic.toTopicInfo() + topic.toTopicInfo().copy(content = TopicContent.Encrypted(encryptedContent, encryptedAes)) } -private fun isKeyVerified(roomId: PrimaryKey, encryptedAes: Map): Boolean { - val toSet = RoomJoins.selectAll().where { - RoomJoins.roomId eq roomId - }.map { - RoomJoin.wrapRow(it) - }.map { - it.uid - }.toSet() - return toSet.minus(encryptedAes.keys).isEmpty() +private suspend fun isKeyVerified(roomId: PrimaryKey, encryptedAes: Map): Result { + return DatabaseFactory.mapQuery({ + RoomJoin.wrapRow(this) + }) { + RoomJoins.selectAll().where { + RoomJoins.roomId eq roomId + } + }.map { value -> + value.map { + it.uid + }.toSet().minus(encryptedAes.keys).isEmpty() + } } diff --git a/server/src/main/kotlin/com/storyteller_f/a/server/auth/Auth.kt b/server/src/main/kotlin/com/storyteller_f/a/server/auth/Auth.kt index a5dbd95..6b57f89 100644 --- a/server/src/main/kotlin/com/storyteller_f/a/server/auth/Auth.kt +++ b/server/src/main/kotlin/com/storyteller_f/a/server/auth/Auth.kt @@ -3,12 +3,16 @@ package com.storyteller_f.a.server.auth import com.perraco.utils.SnowflakeFactory import com.storyteller_f.Backend import com.storyteller_f.DatabaseFactory -import com.storyteller_f.a.server.* import com.storyteller_f.a.server.BuildConfig +import com.storyteller_f.a.server.protectedContent import com.storyteller_f.a.server.service.toFinalUserInfo import com.storyteller_f.a.server.service.toUserInfo +import com.storyteller_f.a.server.unProtectedContent import com.storyteller_f.shared.* import com.storyteller_f.shared.type.PrimaryKey +import com.storyteller_f.shared.type.toPrimaryKey +import com.storyteller_f.shared.utils.downgrade +import com.storyteller_f.shared.utils.mapResult import com.storyteller_f.shared.utils.now import com.storyteller_f.tables.User import com.storyteller_f.tables.Users @@ -25,17 +29,31 @@ import io.ktor.server.sessions.* import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid -data class CustomCredential(val id: PrimaryKey, val sig: String) +sealed class CustomCredential(open val sig: String) { + data class IdCredential(val id: PrimaryKey, override val sig: String) : CustomCredential(sig) + data class AidCredential(val aid: String, override val sig: String) : CustomCredential(sig) +} private fun HttpAuthHeader.Parameterized.customCredential(): CustomCredential? { - val id = parameters.firstOrNull { - it.name == "id" - }?.value?.toULong() val sig = parameters.firstOrNull { it.name == "sig" }?.value - return if (id != null && sig != null) { - CustomCredential(id, sig) + return if (sig != null) { + val id = parameters.firstOrNull { + it.name == "id" + }?.value?.toLong() + if (id != null) { + CustomCredential.IdCredential(id, sig) + } else { + val aid = parameters.firstOrNull { + it.name == "aid" + }?.value + if (aid != null) { + CustomCredential.AidCredential(aid, sig) + } else { + null + } + } } else { null } @@ -109,11 +127,15 @@ fun Application.configureAuth(backend: Backend) { install(Authentication) { custom { validate { session, call, credential -> - when { - session is UserSession.Success -> CustomPrincipal(session.id) - credential != null -> call.checkApiRequest(credential, session) - backend.config.isProd -> null - else -> checkDevWsLink(call) + when (session) { + is UserSession.Success -> CustomPrincipal(session.id) + is UserSession.Pending -> { + when { + credential != null -> call.checkApiRequest(credential, session) + backend.config.isProd -> null + else -> checkDevWsLink(call) + } + } } } challenge { _, call -> @@ -163,15 +185,16 @@ private suspend fun RoutingContext.signIn(backend: Backend) { Users.address eq pack.ad } } - if (userTriple != null) { - val (info, icon, publicKey) = userTriple + userTriple.downgrade { + BadRequestException("user not found") + }.onSuccess { (info, icon, publicKey) -> if (verify(publicKey, pack.sig, f)) { call.respond(toFinalUserInfo(info to icon, backend = backend)) } else { - call.respond(HttpStatusCode.BadRequest) + call.respond(HttpStatusCode.BadRequest, "verify failed.") } - } else { - call.respond(HttpStatusCode.BadRequest) + }.onFailure { + call.respond(HttpStatusCode.BadRequest, it.message.toString()) } } @@ -180,21 +203,29 @@ private suspend fun RoutingContext.signUp(backend: Backend) { val data = call.getData() val f = finalData(data) if (verify(pack.pk, pack.sig, f)) { - if (!DatabaseFactory.empty { - User.find { - Users.publicKey eq pack.pk + DatabaseFactory.isEmpty { + User.find { + Users.publicKey eq pack.pk + } + }.mapResult { bool -> + if (bool) { + val ad = calcAddress(pack.pk) + val newId = SnowflakeFactory.nextId() + val name = backend.nameService.parse(newId) + DatabaseFactory.query({ + toUserInfo() to null + }) { + createUser(User(null, pack.pk, ad, null, name, newId, now())) + }.mapResult { value -> + toFinalUserInfo(value, backend) } - }) { - call.respond(HttpStatusCode.BadRequest, "User exists.") - } else { - val ad = calcAddress(pack.pk) - val newId = SnowflakeFactory.nextId() - val name = backend.nameService.parse(newId) - call.respond(DatabaseFactory.query({ - toUserInfo() to null - }) { - createUser(User(null, pack.pk, ad, null, name, newId, now())) - }.let { toFinalUserInfo(it, backend) }) + } else { + Result.failure(BadRequestException("User exists.")) + } + }.onSuccess { + call.respond(it) + }.onFailure { exception -> + call.respond(HttpStatusCode.BadRequest, exception.message.toString()) } } else { call.respond(HttpStatusCode.BadRequest, "Verify failed.") @@ -203,63 +234,53 @@ private suspend fun RoutingContext.signUp(backend: Backend) { private suspend fun ApplicationCall.checkApiRequest( credential: CustomCredential, - session: UserSession + session: UserSession.Pending ): CustomPrincipal? { val sig = credential.sig - val id = credential.id - return when (session) { - is UserSession.Success -> { - CustomPrincipal(session.id) - } - - is UserSession.Pending -> { - verifySignature(sig, id, session) - } - } -} - -private suspend fun ApplicationCall.verifySignature( - sig: String, - id: PrimaryKey, - session: UserSession.Pending -) = when { - !BuildConfig.IS_PROD && sig == id.toString() -> { - if (DatabaseFactory.dbQuery { - User.findById(id) != null - }) { - saveSuccessSession(session, id) - CustomPrincipal(id) - } else { - null - } - } - - sig.isNotBlank() && session.data.isNotBlank() -> { - DatabaseFactory.first({ - this - }, { - it[Users.publicKey] - }) { - Users.select(Users.publicKey).where { - Users.id eq id - } - }?.let { pubKey -> - if (verify( - pubKey, - sig, - finalData(session.data) - ) - ) { + @Suppress("KotlinConstantConditions") + return when { + !BuildConfig.IS_PROD && credential is CustomCredential.IdCredential && sig == credential.id.toString() -> { + val id = credential.id + if (DatabaseFactory.dbQuery { + User.findById(id) != null + }.getOrNull() == true) { saveSuccessSession(session, id) CustomPrincipal(id) } else { null } } - } - else -> { - null + sig.isNotBlank() && session.data.isNotBlank() -> { + DatabaseFactory.first({ + this + }, { + it[Users.publicKey] to it[Users.id] + }) { + Users.select(listOf(Users.publicKey, Users.id)).where { + when (credential) { + is CustomCredential.AidCredential -> Users.aid eq credential.aid + is CustomCredential.IdCredential -> Users.id eq credential.id + } + } + }.getOrNull()?.let { (pubKey, id) -> + if (verify( + pubKey, + sig, + finalData(session.data) + ) + ) { + saveSuccessSession(session, id) + CustomPrincipal(id) + } else { + null + } + } + } + + else -> { + null + } } } @@ -273,12 +294,12 @@ private fun ApplicationCall.saveSuccessSession( private suspend fun checkDevWsLink(call: ApplicationCall): CustomPrincipal? { val did = call.request.queryParameters["did"] return if (did?.all { it.isDigit() } == true) { - val id = did.toULong() + val id = did.toPrimaryKey() DatabaseFactory.queryNotNull({ CustomPrincipal(id) }) { User.findById(id) - } + }.getOrNull() } else { null } diff --git a/server/src/main/kotlin/com/storyteller_f/a/server/common/Pagination.kt b/server/src/main/kotlin/com/storyteller_f/a/server/common/Pagination.kt index 6c4a84b..1310144 100644 --- a/server/src/main/kotlin/com/storyteller_f/a/server/common/Pagination.kt +++ b/server/src/main/kotlin/com/storyteller_f/a/server/common/Pagination.kt @@ -4,15 +4,20 @@ import com.storyteller_f.BaseTable import com.storyteller_f.shared.obj.Pagination import com.storyteller_f.shared.obj.ServerResponse import com.storyteller_f.shared.type.PrimaryKey +import com.storyteller_f.shared.utils.mapCatchingNotNull +import com.storyteller_f.shared.utils.mapResult +import com.storyteller_f.types.PaginationResult import io.ktor.server.routing.* import io.ktor.util.converters.* import org.jetbrains.exposed.sql.Query import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.andWhere +import kotlin.reflect.KClass -inline fun RoutingContext.pagination( +suspend fun RoutingContext.pagination( + pageTokenType: KClass, nextKeyBuilder: (T) -> String, - block: (PageTokenType?, PageTokenType?, Int) -> Result, Long>?> + block: suspend (R?, R?, Int) -> Result?> ): Result?> { val v = kotlin.runCatching { val size = call.queryParameters.getOrFailCompact("size") @@ -26,47 +31,37 @@ inline fun RoutingContext.pagination( require(nextPageToken.isNullOrBlank() || prePageToken.isNullOrBlank()) { "Invalid query" } - val (parsedPrePageToken, parsedNextPageToken) = if (!nextPageToken.isNullOrBlank()) { - null to if (PageTokenType::class == ULong::class) { - nextPageToken.toULong() as PageTokenType - } else { - DefaultConversionService.fromValue(nextPageToken, PageTokenType::class) as PageTokenType - } - } else if (!prePageToken.isNullOrBlank()) { - if (PageTokenType::class == ULong::class) { - prePageToken.toULong() as PageTokenType - } else { - DefaultConversionService.fromValue(prePageToken, PageTokenType::class) as PageTokenType - } to null - } else { - null to null + val (parsedPrePageToken, parsedNextPageToken) = when { + !nextPageToken.isNullOrBlank() -> null to getPageToken(pageTokenType, nextPageToken) + !prePageToken.isNullOrBlank() -> getPageToken(pageTokenType, prePageToken) to null + else -> null to null } Triple(parsedPrePageToken, parsedNextPageToken, size) } - return when { - v.isSuccess -> { - val (prePageToken, nextPageToken, size) = v.getOrThrow() - block(prePageToken, nextPageToken, size).map { - it?.let { (list, count) -> - val next = if (size == list.size) { - nextKeyBuilder(list.last()) - } else { - null - } - val pre = if (list.isNotEmpty()) { - nextKeyBuilder(list.first()) - } else { - null - } - ServerResponse(list, Pagination(next, pre, count)) - } + return v.mapResult { (prePageToken, nextPageToken, size) -> + block(prePageToken, nextPageToken, size).mapCatchingNotNull { (list, count) -> + val next = if (size == list.size) { + nextKeyBuilder(list.last()) + } else { + null } + val pre = if (list.isNotEmpty()) { + nextKeyBuilder(list.first()) + } else { + null + } + ServerResponse(list, Pagination(next, pre, count)) } - - else -> Result.failure(v.exceptionOrNull()!!) } } +@Suppress("UNCHECKED_CAST") +fun getPageToken(pageTokenType: KClass, pageToken: String): R? = if (pageTokenType == ULong::class) { + pageToken.toULongOrNull() as? R +} else { + DefaultConversionService.fromValue(pageToken, pageTokenType) as? R +} + fun Query.bindPaginationQuery( table: BaseTable, prePageToken: PrimaryKey?, diff --git a/server/src/main/kotlin/com/storyteller_f/a/server/common/Route.kt b/server/src/main/kotlin/com/storyteller_f/a/server/common/Route.kt index 52fc448..6e95019 100644 --- a/server/src/main/kotlin/com/storyteller_f/a/server/common/Route.kt +++ b/server/src/main/kotlin/com/storyteller_f/a/server/common/Route.kt @@ -16,8 +16,7 @@ inline fun Parameters.checkParameter( block: (T) -> Result ): Result { val v = runCatching { - val value = getOrFailCompact(name) - value + getOrFailCompact(name) } return when { v.isSuccess -> block(v.getOrThrow()) diff --git a/server/src/main/kotlin/com/storyteller_f/a/server/service/Community.kt b/server/src/main/kotlin/com/storyteller_f/a/server/service/Community.kt index 5620f37..005d9f8 100644 --- a/server/src/main/kotlin/com/storyteller_f/a/server/service/Community.kt +++ b/server/src/main/kotlin/com/storyteller_f/a/server/service/Community.kt @@ -5,8 +5,14 @@ import com.storyteller_f.DatabaseFactory import com.storyteller_f.a.server.common.bindPaginationQuery import com.storyteller_f.shared.model.CommunityInfo import com.storyteller_f.shared.type.PrimaryKey +import com.storyteller_f.shared.utils.mapResult +import com.storyteller_f.shared.utils.mapResultNotNull import com.storyteller_f.shared.utils.now -import com.storyteller_f.tables.* +import com.storyteller_f.tables.Communities +import com.storyteller_f.tables.Community +import com.storyteller_f.tables.CommunityJoins +import com.storyteller_f.tables.createCommunityJoin +import com.storyteller_f.types.PaginationResult import kotlinx.datetime.LocalDateTime import org.jetbrains.exposed.sql.JoinType import org.jetbrains.exposed.sql.ResultRow @@ -27,19 +33,15 @@ fun Community.toCommunityIfo( ) suspend fun getCommunity(communityId: PrimaryKey, backend: Backend): Result { - return runCatching { - getCommunityInternal(backend) { - Community.findById(communityId) - } + return getCommunityInternal(backend) { + Community.findById(communityId) } } suspend fun getCommunityByAid(communityAid: String, backend: Backend): Result { - return runCatching { - getCommunityInternal(backend) { - Community.find { - Communities.aid eq communityAid - } + return getCommunityInternal(backend) { + Community.find { + Communities.aid eq communityAid } } } @@ -49,18 +51,19 @@ private suspend fun getCommunityInternal(backend: Backend, searchCommunity: susp Triple(toCommunityIfo(now()), icon, poster) }, { Community.wrapRow(it) - }, searchCommunity)?.let { (info, iconName, coverName) -> - val (iconUrl, coverUrl) = backend.mediaService.get("apic", listOf(iconName, coverName)) - info.copy(icon = getMediaInfo(iconUrl), poster = getMediaInfo(coverUrl)) + }, searchCommunity).mapResultNotNull { (info, iconName, coverName) -> + backend.mediaService.get("apic", listOf(iconName, coverName)).map { (iconUrl, coverUrl) -> + info.copy(icon = getMediaInfo(iconUrl), poster = getMediaInfo(coverUrl)) + } } suspend fun joinCommunity( uid: PrimaryKey, communityId: PrimaryKey -) = runCatching { - DatabaseFactory.dbQuery { - createCommunityJoin(uid, communityId) - }.insertedCount > 0 +) = DatabaseFactory.dbQuery { + createCommunityJoin(uid, communityId) +}.map { + it.insertedCount > 0 } suspend fun searchCommunities( @@ -69,22 +72,24 @@ suspend fun searchCommunities( prePageToken: PrimaryKey?, nextPageToken: PrimaryKey?, size: Int -): Result, Long>> { - return runCatching { - val list = DatabaseFactory.mapQuery({ - Triple(toCommunityIfo(null), icon, poster) - }, Community::wrapRow) { - val query = Community.find { - Communities.name like "%$word%" - } - query.bindPaginationQuery(Communities, prePageToken, nextPageToken, size) +): Result?> { + return DatabaseFactory.mapQuery({ + Triple(toCommunityIfo(null), icon, poster) + }, Community::wrapRow) { + val query = Community.find { + Communities.name like "%$word%" } - val count = DatabaseFactory.count { + query.bindPaginationQuery(Communities, prePageToken, nextPageToken, size) + }.mapResult { value -> + DatabaseFactory.count { Community.find { Communities.name like "%$word%" } + }.mapResult { value1 -> + parseCommunityList(backend, value).map { value -> + PaginationResult(value, value1) + } } - parseCommunityList(backend, list) to count } } @@ -94,44 +99,47 @@ suspend fun searchJoinedCommunities( pre: PrimaryKey?, next: PrimaryKey?, size: Int -): Result, Long>> { - return runCatching { - val list = DatabaseFactory.mapQuery({ - val (community, joinTime) = this - Triple(community.toCommunityIfo(joinTime), community.icon, community.poster) - }, { - val community = Community.wrapRow(it) - val joinTime = it[CommunityJoins.joinTime] - community to joinTime - }) { - val query = Communities.join(CommunityJoins, JoinType.INNER, Communities.id, CommunityJoins.communityId) - .select(Communities.fields + CommunityJoins.joinTime) - .where { - CommunityJoins.uid eq uid - } - query.bindPaginationQuery(Communities, pre, next, size) - } - val count = DatabaseFactory.dbQuery { +): Result?> { + return DatabaseFactory.mapQuery({ + val (community, joinTime) = this + Triple(community.toCommunityIfo(joinTime), community.icon, community.poster) + }, { + val community = Community.wrapRow(it) + val joinTime = it[CommunityJoins.joinTime] + community to joinTime + }) { + val query = Communities.join(CommunityJoins, JoinType.INNER, Communities.id, CommunityJoins.communityId) + .select(Communities.fields + CommunityJoins.joinTime) + .where { + CommunityJoins.uid eq uid + } + query.bindPaginationQuery(Communities, pre, next, size) + }.mapResult { list -> + DatabaseFactory.dbQuery { Communities.join(CommunityJoins, JoinType.INNER, Communities.id, CommunityJoins.communityId) .selectAll() .where { CommunityJoins.uid eq uid }.count() + }.mapResult { count -> + parseCommunityList(backend, list).map { value -> + PaginationResult(value, count) + } } - parseCommunityList(backend, list) to count } } private fun parseCommunityList( backend: Backend, list: List> -): List { - val icons = backend.mediaService.get("apic", list.flatMap { (_, icon, poster) -> +): Result> { + return backend.mediaService.get("apic", list.flatMap { (_, icon, poster) -> listOf(icon, poster) - }) - return list.mapIndexed { i, communityPair -> - val first = icons[i * 2] - val second = icons[i * 2 + 1] - communityPair.first.copy(icon = getMediaInfo(first), poster = getMediaInfo(second)) + }).map { icons -> + list.mapIndexed { i, communityPair -> + val first = icons[i * 2] + val second = icons[i * 2 + 1] + communityPair.first.copy(icon = getMediaInfo(first), poster = getMediaInfo(second)) + } } } diff --git a/server/src/main/kotlin/com/storyteller_f/a/server/service/Room.kt b/server/src/main/kotlin/com/storyteller_f/a/server/service/Room.kt index a13205f..19a61a0 100644 --- a/server/src/main/kotlin/com/storyteller_f/a/server/service/Room.kt +++ b/server/src/main/kotlin/com/storyteller_f/a/server/service/Room.kt @@ -5,8 +5,12 @@ import com.storyteller_f.DatabaseFactory import com.storyteller_f.a.server.common.bindPaginationQuery import com.storyteller_f.shared.model.RoomInfo import com.storyteller_f.shared.type.PrimaryKey +import com.storyteller_f.shared.utils.mapResult +import com.storyteller_f.shared.utils.mapResultNotNull +import com.storyteller_f.shared.utils.now import com.storyteller_f.tables.* import com.storyteller_f.tables.Rooms +import com.storyteller_f.types.PaginationResult import io.ktor.server.plugins.* import kotlinx.datetime.LocalDateTime import org.jetbrains.exposed.sql.* @@ -17,54 +21,58 @@ suspend fun getRoomPubKeys( pre: PrimaryKey?, next: PrimaryKey?, size: Int -) = if (isRoomJoined(roomId, userId)) { - runCatching { - val data = DatabaseFactory.dbQuery { +): Result>?> { + return isRoomJoined(roomId, userId).mapResult { + if (it) { val query = Users.join(RoomJoins, JoinType.INNER, Users.id, RoomJoins.uid) .select(Users.id, Users.publicKey) .where { RoomJoins.roomId eq roomId } - if (next != null) { - query.andWhere { - Users.id less next + DatabaseFactory.mapQuery({ + this[Users.id] to this[Users.publicKey] + }) { + query.bindPaginationQuery(Users, pre, next, size) + }.mapResult { data -> + DatabaseFactory.count { + query + }.map { count -> + PaginationResult(data, count) } - } else if (pre != null) { - query.andWhere { - Users.id greater pre - } - } - query - .limit(size) - .orderBy(Users.id, SortOrder.DESC) - .map { - it[Users.id] to it[Users.publicKey] - } - } - val count = DatabaseFactory.count { - Users.join(RoomJoins, JoinType.INNER, Users.id, RoomJoins.uid).selectAll().where { - RoomJoins.roomId eq roomId } + } else { + Result.failure(ForbiddenException("Permission denied.")) } - data to count } -} else { - Result.failure(ForbiddenException("Permission denied.")) } suspend fun joinRoom( - room: PrimaryKey, - id: PrimaryKey -) = runCatching { - if (DatabaseFactory.dbQuery { - checkRoomIsPrivate(room) - }) { - null + roomId: PrimaryKey, + uid: PrimaryKey +) = DatabaseFactory.queryNotNull({ + toRoomInfo(joinedTime = null) +}, Room::wrapRow) { + Room.findRoomById(roomId) +}.mapResultNotNull { roomInfo -> + val communityId = roomInfo.communityId + if (communityId == null) { + Result.failure(ForbiddenException("Join failed.")) } else { - DatabaseFactory.dbQuery { - addRoomJoin(room, id) + isCommunityJoined(communityId, uid).mapResult { hasJoined -> + if (hasJoined) { + DatabaseFactory.insert { + addRoomJoin(roomId, uid) + }.mapResult { affectedCount -> + if (affectedCount > 0) { + Result.success(roomInfo.copy(joinedTime = now())) + } else { + Result.failure(ForbiddenException("Join failed.")) + } + } + } else { + Result.failure(ForbiddenException("you should join community first.")) + } } - Unit } } @@ -75,50 +83,35 @@ suspend fun searchRooms( preRoomId: PrimaryKey?, nextRoomId: PrimaryKey?, size: Int -): Result, Long>> { - return runCatching { - val r = DatabaseFactory.mapQuery(::mapRoomInfo) { - val baseOp = Op.build { - Rooms.name like "%$word%" - } - val baseFields = Rooms.fields - val query = if (uid != null) { - Rooms - .join(RoomJoins, JoinType.INNER, Rooms.id, RoomJoins.roomId) - .select(baseFields + RoomJoins.joinTime) - .where { - baseOp and (RoomJoins.uid eq uid) - } - } else { - Rooms - .select(baseFields) - .where { - baseOp and Rooms.communityId.isNotNull() - } +): Result?> { + val baseOp = Op.build { + Rooms.name like "%$word%" + } + val baseFields = Rooms.fields + val baseQuery = if (uid != null) { + Rooms + .join(RoomJoins, JoinType.INNER, Rooms.id, RoomJoins.roomId) + .select(baseFields + RoomJoins.joinTime) + .where { + baseOp and (RoomJoins.uid eq uid) } - query.bindPaginationQuery(Rooms, preRoomId, nextRoomId, size) - } - val count = DatabaseFactory.count { - val baseOp = Op.build { - Rooms.name like "%$word%" + } else { + Rooms + .select(baseFields) + .where { + baseOp and Rooms.communityId.isNotNull() } - val baseFields = Rooms.fields - if (uid != null) { - Rooms - .join(RoomJoins, JoinType.INNER, Rooms.id, RoomJoins.roomId) - .select(baseFields + RoomJoins.joinTime) - .where { - baseOp and (RoomJoins.uid eq uid) - } - } else { - Rooms - .select(baseFields) - .where { - baseOp and Rooms.communityId.isNotNull() - } + } + return DatabaseFactory.mapQuery(::mapRoomInfo) { + baseQuery.bindPaginationQuery(Rooms, preRoomId, nextRoomId, size) + }.mapResult { r -> + DatabaseFactory.count { + baseQuery + }.mapResult { count -> + roomsResponse(r, backend).map { value -> + PaginationResult(value, count) } } - roomsResponse(r, backend) to count } } @@ -128,23 +121,22 @@ suspend fun searchJoinedRooms( preRoomId: PrimaryKey?, nextRoomId: PrimaryKey?, size: Int -): Result, Long>> { - return runCatching { - val list = DatabaseFactory.mapQuery(::mapRoomInfo) { - RoomJoins.join(Rooms, JoinType.INNER, RoomJoins.roomId, Rooms.id) - .select(Rooms.fields + RoomJoins.joinTime) - .where { - RoomJoins.uid eq uid - }.bindPaginationQuery(Rooms, preRoomId, nextRoomId, size) +): Result?> { + val baseQuery = RoomJoins.join(Rooms, JoinType.INNER, RoomJoins.roomId, Rooms.id) + .select(Rooms.fields + RoomJoins.joinTime) + .where { + RoomJoins.uid eq uid } - val count = DatabaseFactory.count { - RoomJoins.join(Rooms, JoinType.INNER, RoomJoins.roomId, Rooms.id) - .selectAll() - .where { - RoomJoins.uid eq uid - } + return DatabaseFactory.mapQuery(::mapRoomInfo) { + baseQuery.bindPaginationQuery(Rooms, preRoomId, nextRoomId, size) + }.mapResult { list -> + DatabaseFactory.count { + baseQuery + }.mapResult { count -> + roomsResponse(list, backend).map { value -> + PaginationResult(value, count) + } } - roomsResponse(list, backend) to count } } @@ -161,50 +153,37 @@ suspend fun searchRoomInCommunity( preRoomId: PrimaryKey?, nextRoomId: PrimaryKey?, size: Int -): Result, Long>> { - return runCatching { - val list = DatabaseFactory.mapQuery({ - val joinedTime = getOrNull(RoomJoins.joinTime) - val room = Room.wrapRow(this) - room.toRoomInfo(joinedTime) to room.icon - }) { - val join = Rooms - .join(Users, JoinType.INNER, Rooms.creator, Users.id) - val query = if (uid != null) { - join - .join(RoomJoins, JoinType.INNER, Rooms.id, RoomJoins.roomId) - .select(Rooms.fields + RoomJoins.joinTime) - .where { - RoomJoins.uid eq uid and (Rooms.communityId eq communityId) - } - } else { - join - .select(Rooms.fields) - .where { - Rooms.communityId eq communityId - } +): Result?> { + val join = Rooms + .join(Users, JoinType.INNER, Rooms.creator, Users.id) + val baseQuery = if (uid != null) { + join + .join(RoomJoins, JoinType.INNER, Rooms.id, RoomJoins.roomId) + .select(Rooms.fields + RoomJoins.joinTime) + .where { + (RoomJoins.uid eq uid) and (Rooms.communityId eq communityId) } - query.bindPaginationQuery(Rooms, preRoomId, nextRoomId, size) - } - val count = DatabaseFactory.count { - val join = Rooms - .join(Users, JoinType.INNER, Rooms.creator, Users.id) - if (uid != null) { - join - .join(RoomJoins, JoinType.INNER, Rooms.id, RoomJoins.roomId) - .select(Rooms.fields + RoomJoins.joinTime) - .where { - (RoomJoins.uid eq uid) and (Rooms.communityId eq communityId) - } - } else { - join - .select(Rooms.fields) - .where { - Rooms.communityId eq communityId - } + } else { + join + .select(Rooms.fields) + .where { + Rooms.communityId eq communityId + } + } + return DatabaseFactory.mapQuery({ + val joinedTime = getOrNull(RoomJoins.joinTime) + val room = Room.wrapRow(this) + room.toRoomInfo(joinedTime) to room.icon + }) { + baseQuery.bindPaginationQuery(Rooms, preRoomId, nextRoomId, size) + }.mapResult { list -> + DatabaseFactory.count { + baseQuery + }.mapResult { count -> + roomsResponse(list, backend = backend).map { value -> + PaginationResult(value, count) } } - roomsResponse(list, backend = backend) to count } } @@ -223,47 +202,47 @@ suspend fun getRoom(roomId: PrimaryKey?, roomAid: String?, uid: PrimaryKey?, bac if (roomId == null && roomAid == null) { return Result.failure(BadRequestException("roomId or roomAid must be set.")) } - return runCatching { - DatabaseFactory.first({ - this - }, ::mapRoomInfo) { - val baseOp = Op.build { - if (roomId != null) { - Rooms.id eq roomId - } else { - Rooms.aid eq roomAid!! - } - } - val baseFields = Rooms.fields - if (uid != null) { - // 检查用户是否加入,查询加入时间 - Rooms - .join(RoomJoins, JoinType.INNER, Rooms.id, RoomJoins.roomId) - .select(baseFields + RoomJoins.joinTime) - .where { - baseOp and (RoomJoins.uid eq uid) - } + return DatabaseFactory.first({ + this + }, ::mapRoomInfo) { + val baseOp = Op.build { + if (roomId != null) { + Rooms.id eq roomId } else { - Rooms - .select(baseFields) - .where { - // 未登录,只能查找社区的聊天室 - baseOp and (Rooms.communityId.isNotNull()) - } + Rooms.aid eq roomAid!! } - }?.let { - val (info, iconName) = it - val icon = backend.mediaService.get("apic", listOf(iconName)).firstOrNull() + } + val baseFields = Rooms.fields + if (uid != null) { + // 检查用户是否加入,查询加入时间 + Rooms + .join(RoomJoins, JoinType.INNER, Rooms.id, RoomJoins.roomId) + .select(baseFields + RoomJoins.joinTime) + .where { + baseOp and (RoomJoins.uid eq uid) + } + } else { + Rooms + .select(baseFields) + .where { + // 未登录,只能查找社区的聊天室 + baseOp and (Rooms.communityId.isNotNull()) + } + } + }.mapResultNotNull { (info, iconName) -> + backend.mediaService.get("apic", listOf(iconName)).map { value -> + val icon = value.firstOrNull() info.copy(icon = getMediaInfo(icon)) } } } -private fun roomsResponse(list: List>, backend: Backend): List { - val icons = backend.mediaService.get("apic", list.map { +private fun roomsResponse(list: List>, backend: Backend): Result> { + return backend.mediaService.get("apic", list.map { it.second - }) - return list.mapIndexed { i, roomPair -> - roomPair.first.copy(icon = getMediaInfo(icons[i])) + }).map { icons -> + list.mapIndexed { i, roomPair -> + roomPair.first.copy(icon = getMediaInfo(icons[i])) + } } } diff --git a/server/src/main/kotlin/com/storyteller_f/a/server/service/SearchWorld.kt b/server/src/main/kotlin/com/storyteller_f/a/server/service/SearchWorld.kt index 5f56968..d2c2933 100644 --- a/server/src/main/kotlin/com/storyteller_f/a/server/service/SearchWorld.kt +++ b/server/src/main/kotlin/com/storyteller_f/a/server/service/SearchWorld.kt @@ -4,33 +4,40 @@ import com.storyteller_f.Backend import com.storyteller_f.DatabaseFactory import com.storyteller_f.a.server.common.bindPaginationQuery import com.storyteller_f.shared.model.TopicContent +import com.storyteller_f.shared.model.TopicInfo import com.storyteller_f.shared.type.ObjectType import com.storyteller_f.shared.type.PrimaryKey -import com.storyteller_f.tables.* -import org.jetbrains.exposed.sql.selectAll +import com.storyteller_f.shared.utils.mapResult +import com.storyteller_f.tables.Topic +import com.storyteller_f.tables.Topics +import com.storyteller_f.types.PaginationResult -suspend fun searchWorld(backend: Backend, preTopicId: PrimaryKey?, nextTopicId: ULong?, size: Int) = runCatching { - val data = DatabaseFactory.mapQuery(Topic::toTopicInfo, Topic::wrapRow) { - val query = Topics - .select(Topics.fields) - .where { - Topics.parentType eq ObjectType.COMMUNITY - } +suspend fun searchWorld( + backend: Backend, + preTopicId: PrimaryKey?, + nextTopicId: PrimaryKey?, + size: Int +): Result?> { + val query = Topics + .select(Topics.fields) + .where { + Topics.parentType eq ObjectType.COMMUNITY + } + return DatabaseFactory.mapQuery(Topic::toTopicInfo, Topic::wrapRow) { query.bindPaginationQuery(Topics, preTopicId, nextTopicId, size) - } - val count = DatabaseFactory.count { - Topics - .selectAll() - .where { - Topics.parentType eq ObjectType.COMMUNITY + }.mapResult { data -> + DatabaseFactory.count { + query + }.mapResult { count -> + backend.topicDocumentService.getDocument(data.map { + it.id + }).map { value -> + PaginationResult(data.mapIndexed { i, t -> + value[i]?.let { + t.copy(content = TopicContent.Plain(it.content)) + } ?: t + }, count) } - } - val topicContents = backend.topicDocumentService.getDocument(data.map { - it.id - }) - Pair(data.mapIndexedNotNull { i, t -> - topicContents[i]?.let { - t.copy(content = TopicContent.Plain(it.content)) } - }, count) + } } diff --git a/server/src/main/kotlin/com/storyteller_f/a/server/service/Topic.kt b/server/src/main/kotlin/com/storyteller_f/a/server/service/Topic.kt index dddc02c..5b35d02 100644 --- a/server/src/main/kotlin/com/storyteller_f/a/server/service/Topic.kt +++ b/server/src/main/kotlin/com/storyteller_f/a/server/service/Topic.kt @@ -15,10 +15,13 @@ import com.storyteller_f.shared.obj.TopicSnapshot import com.storyteller_f.shared.obj.TopicSnapshotPack import com.storyteller_f.shared.type.ObjectType import com.storyteller_f.shared.type.PrimaryKey -import com.storyteller_f.shared.type.Tuple5 +import com.storyteller_f.shared.utils.mapNotNull +import com.storyteller_f.shared.utils.mapResult +import com.storyteller_f.shared.utils.mapResultNotNull import com.storyteller_f.shared.utils.now import com.storyteller_f.tables.* import com.storyteller_f.tables.checkRoomIsPrivate +import com.storyteller_f.types.PaginationResult import io.ktor.server.request.* import io.ktor.server.routing.* import org.jetbrains.exposed.sql.and @@ -30,7 +33,7 @@ suspend fun RoutingContext.addTopicAtCommunity(uid: PrimaryKey, backend: Backend return Result.failure(ForbiddenException("Community only accept unencrypted content.")) } val content = (newTopic.content as TopicContent.Plain).plain - return checkRootWritePermission(newTopic.parentType, newTopic.parentId, uid)?.let { (t, p, hasWrite) -> + return checkRootWritePermission(newTopic.parentType, newTopic.parentId, uid).mapResultNotNull { (t, p, hasWrite) -> if (hasWrite) { val newId = SnowflakeFactory.nextId() val topic = Topic( @@ -43,16 +46,15 @@ suspend fun RoutingContext.addTopicAtCommunity(uid: PrimaryKey, backend: Backend id = newId, createdTime = now(), ) - val info = DatabaseFactory.dbQuery { + DatabaseFactory.dbQuery { val newTopicId = Topic.new(topic) backend.topicDocumentService.saveDocument(listOf(TopicDocument(newTopicId, content))) - topic.toTopicInfo() + topic.toTopicInfo().copy(content = TopicContent.Plain(content)) } - return Result.success(info) } else { Result.failure(ForbiddenException("Permission denied.")) } - } ?: Result.success(null) + } } fun Topic.toTopicInfo(): TopicInfo { @@ -71,22 +73,20 @@ fun Topic.toTopicInfo(): TopicInfo { } suspend fun getTopicSnapshot(id: PrimaryKey, topicId: PrimaryKey, backend: Backend): Result { - return runCatching { - DatabaseFactory.queryNotNull(User::toUserInfo) { - User.findById(id) - }?.let { creatorInfo -> - DatabaseFactory.queryNotNull({ - toTopicInfo() - }) { - Topic.findById(topicId) - }?.let { - checkRootReadPermission(ObjectType.TOPIC, topicId, id)?.let { (_, _, hasRead) -> - if (hasRead) { - getTopicSnapshot(topicId, it, creatorInfo, backend) - } else { - null - } + return DatabaseFactory.queryNotNull(User::toUserInfo) { + User.findById(id) + }.mapResultNotNull { creatorInfo -> + checkRootReadPermission(ObjectType.TOPIC, topicId, id).mapResultNotNull { (_, _, hasRead) -> + if (hasRead) { + DatabaseFactory.queryNotNull({ + toTopicInfo() + }) { + Topic.findById(topicId) + }.mapResultNotNull { value -> + getTopicSnapshot(topicId, value, creatorInfo, backend) } + } else { + Result.failure(ForbiddenException("Permission denied.")) } } } @@ -97,25 +97,28 @@ private suspend fun getTopicSnapshot( it: TopicInfo, creatorInfo: UserInfo, backend: Backend -): TopicSnapshotPack? { - return backend.topicDocumentService.getDocument(listOf(topicId)).firstOrNull()?.let { content -> - DatabaseFactory.queryNotNull(User::toUserInfo) { - User.findById(it.author) - }?.let { authorInfo -> - val snapshot = TopicSnapshot( - authorAddress = if (authorInfo.aid == null) authorInfo.address else null, - authorAid = authorInfo.aid, - content = content.content, - creatorAddress = if (creatorInfo.aid == null) creatorInfo.address else null, - creatorAid = creatorInfo.aid, - topicCreatedTime = it.createdTime, - topicModifiedTime = it.lastModifiedTime, - capturedTime = now() - ) - val hash = calcHash(snapshot, backend) - TopicSnapshotPack(snapshot, hash) +): Result { + return backend.topicDocumentService.getDocument(listOf(topicId)).map { value -> value.firstOrNull() } + .mapResultNotNull { documents -> + DatabaseFactory.queryNotNull(User::toUserInfo) { + User.findById(it.author) + }.map { value -> + value?.let { authorInfo -> + val snapshot = TopicSnapshot( + authorAddress = if (authorInfo.aid == null) authorInfo.address else null, + authorAid = authorInfo.aid, + content = documents.content, + creatorAddress = if (creatorInfo.aid == null) creatorInfo.address else null, + creatorAid = creatorInfo.aid, + topicCreatedTime = it.createdTime, + topicModifiedTime = it.lastModifiedTime, + capturedTime = now() + ) + val hash = calcHash(snapshot, backend) + TopicSnapshotPack(snapshot, hash) + } + } } - } } suspend fun calcHash(snapshot: TopicSnapshot, backend: Backend): String { @@ -151,24 +154,33 @@ suspend fun getTopic( uid: PrimaryKey?, backend: Backend ): Result { - return checkRootReadPermission(ObjectType.TOPIC, topicId, uid)?.let { (_, _, hasRead, hasJoined, isPrivate) -> + return checkRootReadPermission( + ObjectType.TOPIC, + topicId, + uid + ).mapResultNotNull { (_, _, hasRead, hasJoined, isPrivate) -> if (hasRead) { - Result.success(DatabaseFactory.queryNotNull(Topic::toTopicInfo) { + DatabaseFactory.queryNotNull(Topic::toTopicInfo) { Topic.findById(topicId) - }?.let { info -> + }.mapResultNotNull { info -> if (isPrivate) { - DatabaseFactory.dbQuery { getEncryptedTopicContent(listOf(topicId), uid) }.firstOrNull() - ?.let { id -> info.copy(content = id) } + DatabaseFactory.dbQuery { getEncryptedTopicContent(listOf(topicId), uid) }.map { value -> + value.firstOrNull()?.let { id -> info.copy(content = id) } + } } else { - backend.topicDocumentService.getDocument(listOf(topicId)).firstOrNull()?.content?.let { - info.copy(content = TopicContent.Plain(it)) + backend.topicDocumentService.getDocument(listOf(topicId)).map { value -> + value.firstOrNull()?.content?.let { + info.copy(content = TopicContent.Plain(it)) + } } } - }?.copy(hasJoined = hasJoined)) + }.mapNotNull { value -> + value.copy(hasJoined = hasJoined) + } } else { Result.failure(ForbiddenException()) } - } ?: Result.success(null) + } } suspend fun RoutingContext.verifySnapshot(backend: Backend) = runCatching { @@ -185,50 +197,44 @@ suspend fun getTopics( preTopicId: PrimaryKey?, nextTopicId: PrimaryKey?, size: Int -): Result, Long>> { - return runCatching { - val data = DatabaseFactory.mapQuery(Topic::toTopicInfo, Topic::wrapRow) { - Topics - .select(Topics.fields) - .where { - Topics.parentId eq parentId and (Topics.parentType eq parentType) - }.bindPaginationQuery(Topics, preTopicId, nextTopicId, size) +): Result?> { + val baseQuery = Topics + .select(Topics.fields) + .where { + Topics.parentId eq parentId and (Topics.parentType eq parentType) } - val count = DatabaseFactory.count { - Topics - .select(Topics.fields) - .where { - Topics.parentId eq parentId and (Topics.parentType eq parentType) - } - } - val topicContents = checkRootReadPermission(parentType, parentId, uid)?.let { (_, _, hasRead, _, isPrivate) -> - when { - !isPrivate -> { - backend.topicDocumentService.getDocument(data.map { + return DatabaseFactory.mapQuery(Topic::toTopicInfo, Topic::wrapRow) { + baseQuery.bindPaginationQuery(Topics, preTopicId, nextTopicId, size) + }.mapResult { data -> + DatabaseFactory.count { + baseQuery + }.mapResult { count -> + checkRootReadPermission(parentType, parentId, uid).mapResultNotNull { (_, _, hasRead, _, isPrivate) -> + when { + !isPrivate -> backend.topicDocumentService.getDocument(data.map { it.id - }).mapNotNull { - it?.let { it1 -> TopicContent.Plain(it1.content) } + }).map { + it.mapNotNull { + it?.let { it1 -> TopicContent.Plain(it1.content) } + } } - } - hasRead -> { - DatabaseFactory.dbQuery { + hasRead -> DatabaseFactory.dbQuery { getEncryptedTopicContent(data.map { it.id }, uid) } - } - else -> { - return Result.failure(ForbiddenException()) + else -> Result.failure(ForbiddenException()) } + }.map { topicContents -> + PaginationResult(data.mapIndexed { index, l -> + topicContents?.get(index)?.let { + l.copy(content = it) + } ?: l + }, count) } } - data.mapIndexed { index, l -> - topicContents?.get(index)?.let { - l.copy(content = it) - } ?: l - } to count } } @@ -268,67 +274,42 @@ fun getEncryptedTopicContent(topicId: List, uid: PrimaryKey?): List< } } +data class RootReadPermission( + val type: ObjectType, + val id: PrimaryKey, + val hasRead: Boolean, + val hasJoined: Boolean, + val isPrivate: Boolean +) + suspend fun checkRootReadPermission( parentType: ObjectType, parentId: PrimaryKey, uid: PrimaryKey?, -): Tuple5? { +): Result { return when (parentType) { ObjectType.TOPIC -> { DatabaseFactory.queryNotNull({ rootId to rootType }) { Topic.findById(parentId) - }?.let { (rootId, rootType) -> - if (rootType != ObjectType.ROOM) { - Tuple5(rootType, rootId, true, uid?.let { - DatabaseFactory.dbQuery { - isCommunityJoined(rootId, uid) - } - } == true, false) - } else { - val hasJoined = uid?.let { - DatabaseFactory.dbQuery { isRoomJoined(rootId, uid) } - } == true - val isPrivate = DatabaseFactory.dbQuery { checkRoomIsPrivate(rootId) } - if (isPrivate) { - Tuple5( - rootType, - rootId, - hasJoined, - hasJoined, - true - ) - } else { - Tuple5(rootType, rootId, true, hasJoined, false) - } - } + }.mapResultNotNull { (rootId, rootType) -> + checkRootReadPermission(rootType, rootId, uid) } } ObjectType.ROOM -> { - val hasJoined = uid?.let { - DatabaseFactory.dbQuery { - isRoomJoined(parentId, it) + isRoomJoined(parentId, uid).mapResult { hasJoined -> + checkRoomIsPrivate(parentId).map { isPrivate -> + RootReadPermission(parentType, parentId, hasJoined, hasJoined, isPrivate) } - } == true - val isPrivate = DatabaseFactory.dbQuery { - checkRoomIsPrivate(parentId) - } - if (isPrivate) { - Tuple5(parentType, parentId, hasJoined, hasJoined, true) - } else { - Tuple5(parentType, parentId, true, hasJoined, false) } } ObjectType.COMMUNITY -> { - val hasJoined = uid?.let { - DatabaseFactory.dbQuery { - isCommunityJoined(parentId, it) - } - } == true - Tuple5(parentType, parentId, true, hasJoined, false) + isCommunityJoined(parentId, uid).map { hasJoined -> + RootReadPermission(parentType, parentId, true, hasJoined, false) + } } ObjectType.USER -> TODO() @@ -339,38 +320,59 @@ suspend fun checkRootWritePermission( parentType: ObjectType, parentId: PrimaryKey, uid: PrimaryKey, -): Triple? { +): Result?> { return when (parentType) { ObjectType.TOPIC -> { DatabaseFactory.queryNotNull({ rootId to rootType }) { Topic.findById(parentId) - }?.let { (rootId, rootType) -> - if (rootType == ObjectType.ROOM) { - Triple(rootType, rootId, DatabaseFactory.dbQuery { - isRoomJoined(rootId, uid) - }) - } else { - Triple(rootType, rootId, DatabaseFactory.dbQuery { - isCommunityJoined(rootId, uid) - }) - } + }.mapResultNotNull { (rootId, rootType) -> + checkRootWritePermission(rootType, rootId, uid) } } ObjectType.ROOM -> { - Triple(parentType, parentId, DatabaseFactory.dbQuery { - isRoomJoined(parentId, uid) - }) + isRoomJoined(parentId, uid).map { hasJoined -> + Triple(parentType, parentId, hasJoined) + } } ObjectType.COMMUNITY -> { - Triple(parentType, parentId, DatabaseFactory.dbQuery { - isCommunityJoined(parentId, uid) - }) + isCommunityJoined(parentId, uid).map { hasJoined -> + Triple(parentType, parentId, hasJoined) + } } ObjectType.USER -> TODO() } } + +suspend fun searchTopics( + nextTopicId: PrimaryKey?, + size: Int, + word: List, + backend: Backend +): Result?> { + return backend.topicDocumentService.searchDocument(word, size, nextTopicId).mapResult { documents -> + val map = documents.list.groupBy { + it.id + } + val ids = documents.list.map { + it.id + } + DatabaseFactory.mapQuery(Topic::toTopicInfo, Topic::wrapRow) { + Topics.select(Topics.fields).where { + Topics.id inList ids + } + }.map { infos -> + infos.mapNotNull { t -> + map[t.id]?.let { + t.copy(content = TopicContent.Plain(it.first().content)) + } + } + }.map { value -> + PaginationResult(value, documents.total) + } + } +} diff --git a/server/src/main/kotlin/com/storyteller_f/a/server/service/User.kt b/server/src/main/kotlin/com/storyteller_f/a/server/service/User.kt index b990ade..825a1e6 100644 --- a/server/src/main/kotlin/com/storyteller_f/a/server/service/User.kt +++ b/server/src/main/kotlin/com/storyteller_f/a/server/service/User.kt @@ -5,73 +5,90 @@ import com.storyteller_f.DatabaseFactory import com.storyteller_f.shared.model.MediaInfo import com.storyteller_f.shared.model.UserInfo import com.storyteller_f.shared.type.PrimaryKey +import com.storyteller_f.shared.utils.mapResult +import com.storyteller_f.shared.utils.mapResultNotNull import com.storyteller_f.tables.User import com.storyteller_f.tables.Users import io.ktor.server.plugins.* import io.ktor.server.request.* import io.ktor.server.routing.* -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.update fun User.toUserInfo(): UserInfo { return UserInfo(id, address, 0, aid, nickname, null) } -fun toFinalUserInfo(p: Pair, backend: Backend): UserInfo { +fun toFinalUserInfo(p: Pair, backend: Backend): Result { val (userInfo, icon) = p - val avatar = backend.mediaService.get("apic", listOf(icon)).firstOrNull()?.let { - MediaInfo(it) + return backend.mediaService.get("apic", listOf(icon)).map { value -> + userInfo.copy(avatar = value.firstOrNull()?.let { + MediaInfo(it) + }) } - return userInfo.copy(avatar = avatar) } suspend fun RoutingContext.getUser( it: PrimaryKey, backend: Backend -) = runCatching { - DatabaseFactory.queryNotNull({ - toUserInfo() to icon - }) { - User.findById(it) - }?.let { toFinalUserInfo(it, backend) } +) = DatabaseFactory.queryNotNull({ + toUserInfo() to icon +}) { + User.findById(it) +}.mapResultNotNull { + toFinalUserInfo(it, backend) } suspend fun RoutingContext.getUserByAid( aid: String, backend: Backend -) = runCatching { - DatabaseFactory.first({ - toUserInfo() to icon - }, User::wrapRow) { - User.find { - Users.aid eq aid - } - }?.let { toFinalUserInfo(it, backend) } +) = DatabaseFactory.first({ + toUserInfo() to icon +}, User::wrapRow) { + User.find { + Users.aid eq aid + } +}.mapResultNotNull { + toFinalUserInfo(it, backend) } -suspend fun RoutingContext.updateUser(id: PrimaryKey) = - runCatching { - val newUser = call.receive() +suspend fun RoutingContext.updateUser(id: PrimaryKey): Result { + val newUser = call.receive() + val result1 = listOf(suspend { if (!newUser.aid.isNullOrBlank()) { // check aid is null - if (DatabaseFactory.queryNotNull({ - aid - }) { - User.findById(id) - } != null) { - throw BadRequestException("aid is not null.") - } - } - DatabaseFactory.dbQuery { - Users.update({ - Users.id eq id + DatabaseFactory.queryNotNull({ + aid }) { - if (newUser.nickname.isNotBlank()) { - it[nickname] = newUser.nickname - } - if (!newUser.aid.isNullOrBlank()) { - it[aid] = newUser.aid + User.findById(id) + }.mapResult { + if (it != null) { + Result.failure(BadRequestException("aid is not null.")) + } else { + Result.success(true) } } + } else { + Result.success(true) + } + }).firstNotNullOfOrNull { + val result = it() + if (result.getOrNull() == true) { + null + } else { + result } } + if (result1 != null) return result1.map { 0 } + return DatabaseFactory.dbQuery { + Users.update({ + Users.id eq id + }) { + if (newUser.nickname.isNotBlank()) { + it[nickname] = newUser.nickname + } + if (!newUser.aid.isNullOrBlank()) { + it[aid] = newUser.aid + } + } + } +} diff --git a/server/src/test/kotlin/CommunityTest.kt b/server/src/test/kotlin/CommunityTest.kt index ec7a66b..baf0d24 100644 --- a/server/src/test/kotlin/CommunityTest.kt +++ b/server/src/test/kotlin/CommunityTest.kt @@ -2,7 +2,6 @@ import com.perraco.utils.SnowflakeFactory import com.storyteller_f.DatabaseFactory import com.storyteller_f.a.client_lib.* import com.storyteller_f.buildBackendFromEnv -import com.storyteller_f.naming.NameService import com.storyteller_f.readEnv import com.storyteller_f.shared.hmacSign import com.storyteller_f.shared.hmacVerify @@ -11,10 +10,10 @@ import com.storyteller_f.shared.model.TopicContent import com.storyteller_f.shared.model.TopicInfo import com.storyteller_f.shared.newHmacSha512 import com.storyteller_f.shared.obj.NewTopic -import com.storyteller_f.shared.obj.TopicSnapshotPack import com.storyteller_f.shared.type.DEFAULT_PRIMARY_KEY import com.storyteller_f.shared.type.ObjectType import com.storyteller_f.shared.type.PrimaryKey +import com.storyteller_f.shared.type.toPrimaryKeyOrNull import com.storyteller_f.shared.utils.now import com.storyteller_f.tables.Community import io.ktor.client.call.* @@ -71,29 +70,15 @@ class CommunityTest { } } - @Test - fun `test topic snapshot`() { - test { client -> - session(client) { - val communityId = createCommunity() - client.joinCommunity(communityId) - val topicInfo = client.createNewTopic(ObjectType.COMMUNITY, communityId, "hello").body() - val pack = client.getTopicSnapshot(topicInfo.id).body() - assertEquals("true", client.verifySnapshot(pack).bodyAsText()) - } - } - } - @Test fun `test communities pagination`() { test { client -> val communities = buildList { repeat(10) { val newId = SnowflakeFactory.nextId() - val id = DatabaseFactory.dbQuery { + DatabaseFactory.dbQuery { Community.new(Community("aid$it", "name", null, DEFAULT_PRIMARY_KEY, null, newId, now())) - } - add(id) + }.getOrThrow().let(::add) } } session(client) { @@ -105,7 +90,7 @@ class CommunityTest { while (true) { val res = client.getJoinCommunities(lastCommunityId, 3) val pagination = res.pagination!! - lastCommunityId = pagination.nextPageToken?.toULong() + lastCommunityId = pagination.nextPageToken?.toPrimaryKeyOrNull() sum += res.data.size if (lastCommunityId == null) { assertEquals(pagination.total, sum) @@ -120,7 +105,7 @@ class CommunityTest { val newId = SnowflakeFactory.nextId() return DatabaseFactory.dbQuery { Community.new(Community("aid", "name", null, DEFAULT_PRIMARY_KEY, null, newId, now())) - } + }.getOrThrow() } @Test @@ -139,12 +124,4 @@ class CommunityTest { println(newHmacSha512()) } } - - @Test - fun `test name`() { - runBlocking { - SnowflakeFactory.setMachine(0) - println(NameService().parse(SnowflakeFactory.nextId())) - } - } } diff --git a/server/src/test/kotlin/SnowflakeTest.kt b/server/src/test/kotlin/SnowflakeTest.kt new file mode 100644 index 0000000..81c9311 --- /dev/null +++ b/server/src/test/kotlin/SnowflakeTest.kt @@ -0,0 +1,14 @@ +import com.perraco.utils.SnowflakeFactory +import com.storyteller_f.naming.NameService +import kotlinx.coroutines.runBlocking +import org.junit.Test + +class SnowflakeTest { + @Test + fun `test name`() { + runBlocking { + SnowflakeFactory.setMachine(0) + println(NameService().parse(SnowflakeFactory.nextId())) + } + } +} diff --git a/server/src/test/kotlin/TestBuilder.kt b/server/src/test/kotlin/TestBuilder.kt index 62e8aed..674e6d5 100644 --- a/server/src/test/kotlin/TestBuilder.kt +++ b/server/src/test/kotlin/TestBuilder.kt @@ -25,7 +25,9 @@ fun test(block: suspend (HttpClient) -> Unit) { testApplication { addProvider() val path = Paths.get("../deploy/lucene_data/index") + path.deleteRecursively() val backend = buildBackendFromEnv(readEnv()) + DatabaseFactory.clean(backend.config.databaseConnection) DatabaseFactory.init(backend.config.databaseConnection) environment { config = MapApplicationConfig( diff --git a/server/src/test/kotlin/TopicTest.kt b/server/src/test/kotlin/TopicTest.kt new file mode 100644 index 0000000..47141da --- /dev/null +++ b/server/src/test/kotlin/TopicTest.kt @@ -0,0 +1,59 @@ +import com.perraco.utils.SnowflakeFactory +import com.storyteller_f.DatabaseFactory +import com.storyteller_f.a.client_lib.createNewTopic +import com.storyteller_f.a.client_lib.getTopicSnapshot +import com.storyteller_f.a.client_lib.joinCommunity +import com.storyteller_f.a.client_lib.searchTopics +import com.storyteller_f.a.client_lib.verifySnapshot +import com.storyteller_f.shared.model.TopicInfo +import com.storyteller_f.shared.obj.TopicSnapshotPack +import com.storyteller_f.shared.type.DEFAULT_PRIMARY_KEY +import com.storyteller_f.shared.type.ObjectType +import com.storyteller_f.shared.utils.now +import com.storyteller_f.tables.Community +import io.ktor.client.call.body +import io.ktor.client.statement.bodyAsText +import kotlin.test.Test +import kotlin.test.assertEquals + +class TopicTest { + + @Test + fun `test topic search`() { + test { client -> + val newId = SnowflakeFactory.nextId() + DatabaseFactory.dbQuery { + Community.new(Community("aid", "name", null, DEFAULT_PRIMARY_KEY, null, newId, now())) + } + session(client) { + client.joinCommunity(newId) + val lastTopic = client.createNewTopic(ObjectType.COMMUNITY, newId, "world").body() + client.createNewTopic(ObjectType.COMMUNITY, newId, "sysroot") + val firstTopic = client.createNewTopic(ObjectType.COMMUNITY, newId, "world").body() + + val topics = client.searchTopics(null, 1, listOf("world")) + assertEquals(2, topics.pagination?.total) + assertEquals(1, topics.data.size) + assertEquals(firstTopic.id, topics.data.first().id) + val topics2 = client.searchTopics(topics.data.first().id, 1, listOf("world")) + assertEquals(lastTopic.id, topics2.data.first().id) + } + } + } + + @Test + fun `test topic snapshot`() { + test { client -> + session(client) { + val newId = SnowflakeFactory.nextId() + DatabaseFactory.dbQuery { + Community.new(Community("aid", "name", null, DEFAULT_PRIMARY_KEY, null, newId, now())) + } + client.joinCommunity(newId) + val topicInfo = client.createNewTopic(ObjectType.COMMUNITY, newId, "hello").body() + val pack = client.getTopicSnapshot(topicInfo.id).body() + assertEquals("true", client.verifySnapshot(pack).bodyAsText()) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/storyteller_f/shared/type/PrimaryKey.kt b/shared/src/commonMain/kotlin/com/storyteller_f/shared/type/PrimaryKey.kt index bebbf8f..6f6c874 100644 --- a/shared/src/commonMain/kotlin/com/storyteller_f/shared/type/PrimaryKey.kt +++ b/shared/src/commonMain/kotlin/com/storyteller_f/shared/type/PrimaryKey.kt @@ -1,5 +1,9 @@ package com.storyteller_f.shared.type -typealias PrimaryKey = ULong +typealias PrimaryKey = Long -const val DEFAULT_PRIMARY_KEY: ULong = 0u +const val DEFAULT_PRIMARY_KEY: Long = 0L + +fun String.toPrimaryKey() = toLong() + +fun String.toPrimaryKeyOrNull() = toLongOrNull() diff --git a/shared/src/commonMain/kotlin/com/storyteller_f/shared/utils/Result.kt b/shared/src/commonMain/kotlin/com/storyteller_f/shared/utils/Result.kt new file mode 100644 index 0000000..ff57527 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/storyteller_f/shared/utils/Result.kt @@ -0,0 +1,56 @@ +package com.storyteller_f.shared.utils + +suspend fun Result.mapResult(block: suspend (T) -> Result): Result { + return if (isSuccess) { + try { + block(getOrThrow()) + } catch (e: Throwable) { + Result.failure(e) + } + } else { + Result.failure(exceptionOrNull()!!) + } +} + +suspend fun Result.mapResultNotNull(block: suspend (T) -> Result): Result { + return mapResult { t -> + if (t == null) { + Result.success(null) + } else { + block(t) + } + } +} + +suspend fun Result.mapNotNull(block: suspend (T) -> R?): Result { + return map { value -> + if (value == null) { + null + } else { + block(value) + } + } +} + +suspend fun Result.mapCatchingNotNull(block: suspend (T) -> R?): Result { + return mapCatching { value -> + if (value == null) { + null + } else { + block(value) + } + } +} + +suspend fun Result.downgrade(block: suspend () -> Throwable): Result { + return if (isSuccess) { + val t = getOrNull() + if (t == null) { + Result.failure(block()) + } else { + Result.success(t) + } + } else { + Result.failure(exceptionOrNull()!!) + } +}