From d68d513539a41fd32ac752d890234e20d04d417e Mon Sep 17 00:00:00 2001 From: Nep Date: Sat, 27 Feb 2021 13:31:03 +0900 Subject: [PATCH 01/19] Upgrade Ktor to 1.5.2 --- build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index b9f7d3f3..d8d78dd7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ plugins { } object Versions { - const val Ktor = "1.5.1" + const val Ktor = "1.5.2" const val Penicillin = "6.0.5" const val KMongo = "4.2.3" const val Jsoup = "1.13.1" @@ -54,7 +54,7 @@ object Libraries { repositories { mavenCentral() - // jcenter() + maven(url = "https://kotlin.bintray.com/kotlinx") } From 67ed50b21953d76f746e027685b3a2dec162c24b Mon Sep 17 00:00:00 2001 From: Nep Date: Sat, 27 Feb 2021 21:44:35 +0900 Subject: [PATCH 02/19] Refactoring --- .gitignore | 25 +- build.gradle.kts | 23 +- gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 55616 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- .../common/ImagePlatform.kt | 9 - .../blue.starry.stella/common/PicModel.kt | 63 ---- src/jvmMain/kotlin/blue/starry/stella/App.kt | 54 ++-- .../kotlin/blue/starry/stella/Config.kt | 26 -- .../kotlin/blue/starry/stella/Database.kt | 28 -- src/jvmMain/kotlin/blue/starry/stella/Env.kt | 45 +++ .../kotlin/blue/starry/stella/api/Common.kt | 67 ---- .../starry/stella/api/TagReplaceTableModel.kt | 12 - .../starry/stella/api/endpoints/GetQuery.kt | 305 ------------------ .../stella/api/endpoints/GetQueryTags.kt | 69 ---- .../starry/stella/api/endpoints/GetSummary.kt | 22 -- .../api/endpoints/PatchSensitiveLevel.kt | 61 ---- .../starry/stella/api/endpoints/PutRefresh.kt | 43 --- .../blue/starry/stella/endpoints/Common.kt | 32 ++ .../Media.kt => endpoints/GetMedia.kt} | 6 +- .../blue/starry/stella/endpoints/GetQuery.kt | 77 +++++ .../stella/endpoints/GetQueryFilters.kt | 279 ++++++++++++++++ .../stella/endpoints/GetQueryPipelines.kt | 192 +++++++++++ .../starry/stella/endpoints/GetQueryTags.kt | 71 ++++ .../starry/stella/endpoints/GetSummary.kt | 24 ++ .../stella/endpoints/PatchSensitiveLevel.kt | 55 ++++ .../EditTag.kt => endpoints/PutEditTag.kt} | 43 ++- .../starry/stella/endpoints/PutRefresh.kt | 36 +++ .../starry/stella/models/ApiResponseModel.kt | 17 + .../starry/stella/models}/FileExtension.kt | 6 +- .../starry/stella/models/ImagePlatform.kt | 5 + .../blue/starry/stella/models/PicModel.kt | 70 ++++ .../starry/stella/models/PicSummaryModel.kt | 9 + .../stella/models/PicTagReplaceTableModel.kt | 9 + .../blue/starry/stella/models/PicTagsModel.kt | 8 + .../blue/starry/stella/models}/SortOrder.kt | 6 +- .../blue/starry/stella/worker/Client.kt | 79 +++++ .../starry/stella/worker/MediaRegister.kt | 180 +++++------ .../worker/MissingMediaRefetchWorker.kt | 23 +- .../starry/stella/worker/RefreshWorker.kt | 37 +-- .../stella/worker/platform/NijieClient.kt | 42 +-- .../worker/platform/NijieSourceProvider.kt | 54 ++-- .../stella/worker/platform/PixivClient.kt | 32 +- .../worker/platform/PixivSourceProvider.kt | 50 +-- .../worker/platform/TwitterSourceProvider.kt | 60 ++-- 44 files changed, 1291 insertions(+), 1065 deletions(-) create mode 100644 gradle/wrapper/gradle-wrapper.jar delete mode 100644 src/commonMain/kotlin/blue.starry.stella/common/ImagePlatform.kt delete mode 100644 src/commonMain/kotlin/blue.starry.stella/common/PicModel.kt delete mode 100644 src/jvmMain/kotlin/blue/starry/stella/Config.kt delete mode 100644 src/jvmMain/kotlin/blue/starry/stella/Database.kt create mode 100644 src/jvmMain/kotlin/blue/starry/stella/Env.kt delete mode 100644 src/jvmMain/kotlin/blue/starry/stella/api/Common.kt delete mode 100644 src/jvmMain/kotlin/blue/starry/stella/api/TagReplaceTableModel.kt delete mode 100644 src/jvmMain/kotlin/blue/starry/stella/api/endpoints/GetQuery.kt delete mode 100644 src/jvmMain/kotlin/blue/starry/stella/api/endpoints/GetQueryTags.kt delete mode 100644 src/jvmMain/kotlin/blue/starry/stella/api/endpoints/GetSummary.kt delete mode 100644 src/jvmMain/kotlin/blue/starry/stella/api/endpoints/PatchSensitiveLevel.kt delete mode 100644 src/jvmMain/kotlin/blue/starry/stella/api/endpoints/PutRefresh.kt create mode 100644 src/jvmMain/kotlin/blue/starry/stella/endpoints/Common.kt rename src/jvmMain/kotlin/blue/starry/stella/{api/endpoints/Media.kt => endpoints/GetMedia.kt} (70%) create mode 100644 src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQuery.kt create mode 100644 src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryFilters.kt create mode 100644 src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryPipelines.kt create mode 100644 src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTags.kt create mode 100644 src/jvmMain/kotlin/blue/starry/stella/endpoints/GetSummary.kt create mode 100644 src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchSensitiveLevel.kt rename src/jvmMain/kotlin/blue/starry/stella/{api/endpoints/EditTag.kt => endpoints/PutEditTag.kt} (69%) create mode 100644 src/jvmMain/kotlin/blue/starry/stella/endpoints/PutRefresh.kt create mode 100644 src/jvmMain/kotlin/blue/starry/stella/models/ApiResponseModel.kt rename src/{commonMain/kotlin/blue.starry.stella/common => jvmMain/kotlin/blue/starry/stella/models}/FileExtension.kt (66%) create mode 100644 src/jvmMain/kotlin/blue/starry/stella/models/ImagePlatform.kt create mode 100644 src/jvmMain/kotlin/blue/starry/stella/models/PicModel.kt create mode 100644 src/jvmMain/kotlin/blue/starry/stella/models/PicSummaryModel.kt create mode 100644 src/jvmMain/kotlin/blue/starry/stella/models/PicTagReplaceTableModel.kt create mode 100644 src/jvmMain/kotlin/blue/starry/stella/models/PicTagsModel.kt rename src/{commonMain/kotlin/blue.starry.stella/common => jvmMain/kotlin/blue/starry/stella/models}/SortOrder.kt (88%) create mode 100644 src/jvmMain/kotlin/blue/starry/stella/worker/Client.kt diff --git a/.gitignore b/.gitignore index a1c2a238..d5a46126 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,4 @@ -# Compiled class file -*.class +.idea +.gradle -# Log file -*.log - -# BlueJ files -*.ctxt - -# Mobile Tools for Java (J2ME) -.mtj.tmp/ - -# Package Files # -*.jar -*.war -*.nar -*.ear -*.zip -*.tar.gz -*.rar - -# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml -hs_err_pid* +media diff --git a/build.gradle.kts b/build.gradle.kts index d8d78dd7..f51ef964 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ plugins { kotlin("multiplatform") version "1.4.30" + kotlin("plugin.serialization") version "1.4.30" id("com.github.johnrengelman.shadow") version "6.1.0" id("org.jlleitschuh.gradle.ktlint") version "10.0.0" @@ -10,9 +11,8 @@ plugins { object Versions { const val Ktor = "1.5.2" const val Penicillin = "6.0.5" - const val KMongo = "4.2.3" + const val KMongo = "4.2.4" const val Jsoup = "1.13.1" - const val CommonsLang = "3.8.1" const val JUnit = "5.7.0" @@ -22,15 +22,13 @@ object Versions { } object Libraries { - const val Penicillin = "blue.starry:penicillin:${Versions.Penicillin}" - const val KtorServerCIO = "io.ktor:ktor-server-cio:${Versions.Ktor}" const val KtorLocations = "io.ktor:ktor-locations:${Versions.Ktor}" + const val KtorSerialization = "io.ktor:ktor-serialization:${Versions.Ktor}" const val KtorClientCIO = "io.ktor:ktor-client-cio:${Versions.Ktor}" const val KMongoCoroutine = "org.litote.kmongo:kmongo-coroutine:${Versions.KMongo}" - const val CommonsLang3 = "org.apache.commons:commons-lang3:${Versions.CommonsLang}" - + const val Penicillin = "blue.starry:penicillin:${Versions.Penicillin}" const val Jsoup = "org.jsoup:jsoup:${Versions.Jsoup}" const val KotlinLogging = "io.github.microutils:kotlin-logging:${Versions.KotlinLogging}" @@ -48,7 +46,8 @@ object Libraries { "kotlin.time.ExperimentalTime", "kotlinx.coroutines.ExperimentalCoroutinesApi", "io.ktor.util.KtorExperimentalAPI", - "io.ktor.locations.KtorExperimentalLocationsAPI" + "io.ktor.locations.KtorExperimentalLocationsAPI", + "kotlinx.coroutines.FlowPreview" ) } @@ -70,6 +69,10 @@ kotlin { sourceSets { commonMain { + dependencies { + implementation(Libraries.Penicillin) + implementation(Libraries.KotlinLogging) + } } commonTest { dependencies { @@ -82,18 +85,14 @@ kotlin { dependencies { implementation(kotlin("reflect")) - implementation(Libraries.Penicillin) - implementation(Libraries.KtorServerCIO) implementation(Libraries.KtorLocations) + implementation(Libraries.KtorSerialization) implementation(Libraries.KtorClientCIO) implementation(Libraries.KMongoCoroutine) - implementation(Libraries.CommonsLang3) - implementation(Libraries.Jsoup) - implementation(Libraries.KotlinLogging) implementation(Libraries.LogbackCore) implementation(Libraries.LogbackClassic) implementation(Libraries.Jansi) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..5c2d1cf016b3885f6930543d57b744ea8c220a1a GIT binary patch literal 55616 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 by lazy { - database.getCollection("Pic") -} - -internal val tagReplaceTable: CoroutineCollection by lazy { - database.getCollection("PicTagReplaceTable") -} diff --git a/src/jvmMain/kotlin/blue/starry/stella/Env.kt b/src/jvmMain/kotlin/blue/starry/stella/Env.kt new file mode 100644 index 00000000..e293846e --- /dev/null +++ b/src/jvmMain/kotlin/blue/starry/stella/Env.kt @@ -0,0 +1,45 @@ +package blue.starry.stella + +import kotlin.properties.ReadOnlyProperty + +object Env { + val HOST by stringOrNull + + val HTTP_HOST by string { "0.0.0.0" } + val HTTP_PORT by int { 6742 } + + val DB_HOST by string { "0.0.0.0" } + val DB_PORT by int { 27017 } + val DB_USER by stringOrNull + val DB_PASSWORD by stringOrNull + val DB_NAME by string { "stella" } + + val AUTO_REFRESH_THRESHOLD by long { 6 * 60 * 60 * 1000L } + val CHECK_INTERVAL_MINS by int { 1 } + + val TWITTER_CK by stringOrNull + val TWITTER_CS by stringOrNull + val TWITTER_AT by stringOrNull + val TWITTER_ATS by stringOrNull + val PIXIV_EMAIL by stringOrNull + val PIXIV_PASSWORD by stringOrNull + val NIJIE_EMAIL by stringOrNull + val NIJIE_PASSWORD by stringOrNull +} + +private val stringOrNull: ReadOnlyProperty + get() = ReadOnlyProperty { _, property -> + System.getenv(property.name) + } + +private fun string(default: () -> String): ReadOnlyProperty = ReadOnlyProperty { _, property -> + System.getenv(property.name) ?: default() +} + +private fun int(default: () -> Int): ReadOnlyProperty = ReadOnlyProperty { _, property -> + System.getenv(property.name)?.toIntOrNull() ?: default() +} + +private fun long(default: () -> Long): ReadOnlyProperty = ReadOnlyProperty { _, property -> + System.getenv(property.name)?.toLongOrNull() ?: default() +} diff --git a/src/jvmMain/kotlin/blue/starry/stella/api/Common.kt b/src/jvmMain/kotlin/blue/starry/stella/api/Common.kt deleted file mode 100644 index 433f0b6f..00000000 --- a/src/jvmMain/kotlin/blue/starry/stella/api/Common.kt +++ /dev/null @@ -1,67 +0,0 @@ -package blue.starry.stella.api - -import blue.starry.jsonkt.* -import blue.starry.stella.common.PicModel -import io.ktor.application.* -import io.ktor.http.* -import io.ktor.response.* -import org.bson.Document -import org.bson.json.JsonWriterSettings - -private val pureJsonWriter = JsonWriterSettings.builder().int64Converter { value, writer -> - writer.writeRaw(value.toString()) -}.build() - -fun Document.serialize(): JsonObject { - return toJson(pureJsonWriter).toJsonObject().copy { map -> - map["id"] = (map.remove("_id") as JsonObject)["\$oid"] - } -} - -fun List.serialize(): JsonArray { - return map { - it.serialize() - }.toJsonArray() -} - -fun Document.toPic(): PicModel { - return serialize().parseObject { PicModel(it) } -} - -fun Document.toTagReplaceTable(): TagReplaceTableModel { - return serialize().parseObject { TagReplaceTableModel(it) } -} - -suspend fun ApplicationCall.respondApi(block: suspend () -> JsonElement) { - runCatching { - block() - }.onSuccess { - respondText(contentType = ContentType.Application.Json) { - jsonObjectOf( - "success" to true, - "result" to it, - "error" to null - ).encodeToString() - } - }.onFailure { - respondApiError { - it.message ?: "Internal server error." - } - } -} - -suspend fun ApplicationCall.respondApiError( - code: HttpStatusCode = HttpStatusCode.InternalServerError, - block: () -> String -) { - respondText(contentType = ContentType.Application.Json) { - jsonObjectOf( - "success" to false, - "result" to null, - "error" to mapOf( - "code" to code.value, - "message" to block() - ) - ).encodeToString() - } -} diff --git a/src/jvmMain/kotlin/blue/starry/stella/api/TagReplaceTableModel.kt b/src/jvmMain/kotlin/blue/starry/stella/api/TagReplaceTableModel.kt deleted file mode 100644 index 492be2c1..00000000 --- a/src/jvmMain/kotlin/blue/starry/stella/api/TagReplaceTableModel.kt +++ /dev/null @@ -1,12 +0,0 @@ -@file:Suppress("Unused") - -package blue.starry.stella.api - -import blue.starry.jsonkt.JsonObject -import blue.starry.jsonkt.delegation.JsonModel -import blue.starry.jsonkt.delegation.string - -data class TagReplaceTableModel(override val json: JsonObject): JsonModel { - val from by string - val to by string -} diff --git a/src/jvmMain/kotlin/blue/starry/stella/api/endpoints/GetQuery.kt b/src/jvmMain/kotlin/blue/starry/stella/api/endpoints/GetQuery.kt deleted file mode 100644 index 3fd2c19e..00000000 --- a/src/jvmMain/kotlin/blue/starry/stella/api/endpoints/GetQuery.kt +++ /dev/null @@ -1,305 +0,0 @@ -package blue.starry.stella.api.endpoints - -import blue.starry.stella.api.respondApi -import blue.starry.stella.api.serialize -import blue.starry.stella.collection -import blue.starry.stella.common.SortOrder -import blue.starry.stella.common.toFileExtension -import blue.starry.stella.common.toImagePlatform -import blue.starry.stella.common.toSortOrder -import com.mongodb.client.model.Aggregates -import com.mongodb.client.model.Filters -import com.mongodb.client.model.Sorts -import io.ktor.application.* -import io.ktor.request.* -import io.ktor.routing.* -import org.apache.commons.lang3.time.FastDateFormat -import org.bson.Document -import org.bson.conversions.Bson -import org.litote.kmongo.or -import java.util.* - -private val dateFormat = FastDateFormat.getInstance("yyyy-MM-dd", Locale.ENGLISH) - -fun Route.getQuery() { - get("/query") { - val page = call.parameters["page"]?.toIntOrNull() ?: 0 - val count = call.parameters["count"]?.toIntOrNull() ?: 25 - - val filter = mutableListOf().also { filters -> - val title = call.parameters["title"] - if (!title.isNullOrBlank()) { - filters += Filters.regex("title", title, "im") - } - - val description = call.parameters["description"] - if (!description.isNullOrBlank()) { - filters += Filters.regex("description", description, "im") - } - - val tags = call.parameters["tags"] - if (!tags.isNullOrBlank()) { - filters += tags.split(",").map { - Filters.elemMatch("tags", Filters.regex("value", it.trim(), "i")) - }.reduce { s1, s2 -> - or(s1, s2) - } - } - - val author = call.parameters["author"] - val platform = call.parameters["platform"]?.toImagePlatform() - if (!author.isNullOrBlank()) { - filters += or( - Filters.regex("author.name", author, "im"), Filters.regex("author.username", author, "im") - ) - } - - if (platform != null) { - filters += Filters.eq("platform", platform.name) - } - - val user = call.parameters["user"] - if (!user.isNullOrBlank()) { - filters += Filters.eq("user", user) - } - - val isLocalAccess = call.request.header("X-Local-Access")?.toIntOrNull() == 1 - if (isLocalAccess) { - val levels = call.parameters["sensitive_levels"].orEmpty().split(",").mapNotNull { it.toIntOrNull() } - if (levels.isNotEmpty()) { - filters += Filters.`in`("sensitive_level", levels) - } - } else { - filters += Filters.eq("sensitive_level", 0) - } - - val createdSince = call.parameters["created_since"] - if (createdSince != null) { - runCatching { - dateFormat.parse(createdSince) - }.onSuccess { d -> - filters += Filters.gte("timestamp.created", d.time) - } - } - val createdUntil = call.parameters["created_until"] - if (createdUntil != null) { - runCatching { - dateFormat.parse(createdUntil) - }.onSuccess { d -> - filters += Filters.lte("timestamp.created", d.time) - } - } - - val addedSince = call.parameters["added_since"] - if (addedSince != null) { - runCatching { - dateFormat.parse(addedSince) - }.onSuccess { d -> - filters += Filters.gte("timestamp.added", d.time) - } - } - val addedUntil = call.parameters["added_until"] - if (addedUntil != null) { - runCatching { - dateFormat.parse(addedUntil) - }.onSuccess { d -> - filters += Filters.lte("timestamp.added", d.time) - } - } - - val updatedSince = call.parameters["updated_since"] - if (updatedSince != null) { - runCatching { - dateFormat.parse(updatedSince) - }.onSuccess { d -> - filters += Filters.gte("timestamp.manual_updated", d.time) - } - } - val updatedUntil = call.parameters["updated_until"] - if (updatedUntil != null) { - runCatching { - dateFormat.parse(updatedUntil) - }.onSuccess { d -> - filters += Filters.lte("timestamp.manual_updated", d.time) - } - } - - val extension = call.parameters["ext"]?.toFileExtension() - if (extension != null) { - filters += or(extension.exts.map { Filters.elemMatch("media", Filters.eq("ext", it)) }) - } - - val minRating = call.parameters["min_rating"]?.toIntOrNull() - if (minRating != null) { - filters += Filters.expr("{\$gte: [\$divide: [\"\$rating.score\", \"\$rating.count\"], $minRating]}") - } - val maxRating = call.parameters["max_rating"]?.toIntOrNull() - if (maxRating != null) { - filters += Filters.expr("{\$lte: [\$divide: [\"\$rating.score\", \"\$rating.count\"], $maxRating]}") - } - - val minLike = call.parameters["min_like"]?.toIntOrNull() - val maxLike = call.parameters["max_like"]?.toIntOrNull() - val minBookmark = call.parameters["min_bookmark"]?.toIntOrNull() - val maxBookmark = call.parameters["max_bookmark"]?.toIntOrNull() - val minView = call.parameters["min_view"]?.toIntOrNull() - val maxView = call.parameters["max_view"]?.toIntOrNull() - val minRetweet = call.parameters["min_retweet"]?.toIntOrNull() - val maxRetweet = call.parameters["max_retweet"]?.toIntOrNull() - val minReply = call.parameters["min_reply"]?.toIntOrNull() - val maxReply = call.parameters["max_reply"]?.toIntOrNull() - - if (minLike != null) { - filters += Filters.not(Filters.eq("popularity.like", null)) - filters += Filters.gte("popularity.like", minLike) - } - if (maxLike != null) { - filters += Filters.not(Filters.eq("popularity.like", null)) - filters += Filters.lte("popularity.like", maxLike) - } - if (minBookmark != null) { - filters += Filters.not(Filters.eq("popularity.bookmark", null)) - filters += Filters.gte("popularity.bookmark", minBookmark) - } - if (maxBookmark != null) { - filters += Filters.not(Filters.eq("popularity.bookmark", null)) - filters += Filters.lte("popularity.bookmark", maxBookmark) - } - if (minView != null) { - filters += Filters.not(Filters.eq("popularity.view", null)) - filters += Filters.gte("popularity.view", minView) - } - if (maxView != null) { - filters += Filters.not(Filters.eq("popularity.view", null)) - filters += Filters.lte("popularity.view", maxView) - } - if (minRetweet != null) { - filters += Filters.not(Filters.eq("popularity.retweet", null)) - filters += Filters.gte("popularity.retweet", minRetweet) - } - if (maxRetweet != null) { - filters += Filters.not(Filters.eq("popularity.retweet", null)) - filters += Filters.lte("popularity.retweet", maxRetweet) - } - if (minReply != null) { - filters += Filters.not(Filters.eq("popularity.reply", null)) - filters += Filters.gte("popularity.reply", minReply) - } - if (maxReply != null) { - filters += Filters.not(Filters.eq("popularity.reply", null)) - filters += Filters.lte("popularity.reply", maxReply) - } - }.let { filters -> - if (filters.isNotEmpty()) { - Filters.and(filters) - } else { - null - } - } - - val pipeline = mutableListOf().also { pipelines -> - val order = call.parameters["sort"]?.toSortOrder() ?: SortOrder.ManualUpdatedDescending - when (order) { - SortOrder.AddedDescending -> { - pipelines += Aggregates.sort(Sorts.descending("timestamp.added")) - } - SortOrder.AddedAscending -> { - pipelines += Aggregates.sort(Sorts.ascending("timestamp.added")) - } - SortOrder.CreatedDescending -> { - pipelines += Aggregates.sort(Sorts.descending("timestamp.created")) - } - SortOrder.CreatedAscending -> { - pipelines += Aggregates.sort(Sorts.ascending("timestamp.created")) - } - SortOrder.ManualUpdatedDescending -> { - pipelines += Aggregates.sort(Sorts.descending("timestamp.manual_updated")) - } - SortOrder.ManualUpdatedAscending -> { - pipelines += Aggregates.sort(Sorts.ascending("timestamp.manual_updated")) - } - SortOrder.AutoUpdatedDescending -> { - pipelines += Aggregates.sort(Sorts.descending("timestamp.auto_updated")) - } - SortOrder.AutoUpdatedAscending -> { - pipelines += Aggregates.sort(Sorts.ascending("timestamp.auto_updated")) - } - SortOrder.TitleDescending -> { - pipelines += Aggregates.sort(Sorts.descending("title")) - } - SortOrder.TitleAscending -> { - pipelines += Aggregates.sort(Sorts.ascending("title")) - } - SortOrder.AuthorDescending -> { - pipelines += Aggregates.sort(Sorts.descending("author.name")) - } - SortOrder.AuthorAscending -> { - pipelines += Aggregates.sort(Sorts.ascending("author.name")) - } - SortOrder.RatingDescending -> { - pipelines += Aggregates.sort(Sorts.descending("rating.score")) - } - SortOrder.RatingAscending -> { - pipelines += Aggregates.sort(Sorts.ascending("rating.score")) - } - SortOrder.LikeDescending -> { - pipelines += Aggregates.match(Filters.not(Filters.eq("popularity.like", null))) - pipelines += Aggregates.sort(Sorts.descending("popularity.like")) - } - SortOrder.LikeAscending -> { - pipelines += Aggregates.match(Filters.not(Filters.eq("popularity.like", null))) - pipelines += Aggregates.sort(Sorts.ascending("popularity.like")) - } - SortOrder.BookmarkDescending -> { - pipelines += Aggregates.match(Filters.not(Filters.eq("popularity.bookmark", null))) - pipelines += Aggregates.sort(Sorts.descending("popularity.bookmark")) - } - SortOrder.BookmarkAscending -> { - pipelines += Aggregates.match(Filters.not(Filters.eq("popularity.bookmark", null))) - pipelines += Aggregates.sort(Sorts.ascending("popularity.bookmark")) - } - SortOrder.ViewDescending -> { - pipelines += Aggregates.match(Filters.not(Filters.eq("popularity.view", null))) - pipelines += Aggregates.sort(Sorts.descending("popularity.view")) - } - SortOrder.ViewAscending -> { - pipelines += Aggregates.match(Filters.not(Filters.eq("popularity.view", null))) - pipelines += Aggregates.sort(Sorts.ascending("popularity.view")) - } - SortOrder.RetweetDescending -> { - pipelines += Aggregates.match(Filters.not(Filters.eq("popularity.retweet", null))) - pipelines += Aggregates.sort(Sorts.descending("popularity.retweet")) - } - SortOrder.RetweetAscending -> { - pipelines += Aggregates.match(Filters.not(Filters.eq("popularity.retweet", null))) - pipelines += Aggregates.sort(Sorts.ascending("popularity.retweet")) - } - SortOrder.ReplyDescending -> { - pipelines += Aggregates.match(Filters.not(Filters.eq("popularity.reply", null))) - pipelines += Aggregates.sort(Sorts.descending("popularity.reply")) - } - SortOrder.ReplyAscending -> { - pipelines += Aggregates.match(Filters.not(Filters.eq("popularity.reply", null))) - pipelines += Aggregates.sort(Sorts.ascending("popularity.reply")) - } - SortOrder.Random -> { - pipelines += Aggregates.sample(count) - } - } - - if (filter != null) { - pipelines += Aggregates.match(filter) - } - - if (order != SortOrder.Random) { - pipelines += Aggregates.skip(page * count) - pipelines += Aggregates.limit(count) - } - } - - - call.respondApi { - collection.aggregate(pipeline).toList().serialize() - } - } -} diff --git a/src/jvmMain/kotlin/blue/starry/stella/api/endpoints/GetQueryTags.kt b/src/jvmMain/kotlin/blue/starry/stella/api/endpoints/GetQueryTags.kt deleted file mode 100644 index bc338183..00000000 --- a/src/jvmMain/kotlin/blue/starry/stella/api/endpoints/GetQueryTags.kt +++ /dev/null @@ -1,69 +0,0 @@ -package blue.starry.stella.api.endpoints - -import blue.starry.jsonkt.jsonObjectOf -import blue.starry.stella.api.respondApi -import blue.starry.stella.api.toPic -import blue.starry.stella.collection -import com.mongodb.client.model.Aggregates -import com.mongodb.client.model.Filters -import io.ktor.application.call -import io.ktor.routing.Route -import io.ktor.routing.get -import org.bson.Document -import org.bson.conversions.Bson -import org.bson.types.ObjectId -import org.litote.kmongo.coroutine.aggregate - -fun Route.getQueryTags() { - get("/query/tags") { - val count = call.parameters["count"]?.toIntOrNull() ?: 10 - val random = call.parameters["random"]?.toBoolean() ?: false - - val id = call.parameters["id"] - val existTags = id?.let { - collection.findOne(Filters.eq("_id", ObjectId(it)))?.toPic()?.tags - }.orEmpty().map { - it.value - } - - val filter = mutableListOf().also { filters -> - filters += Filters.not(Filters.size("tags", 0)) - - val name = call.parameters["name"] - if (name != null) { - filters += Filters.elemMatch("tags", Filters.regex("value", name, "i")) - } - - if (existTags.isNotEmpty()) { - filters += Filters.or(existTags.map { - Filters.elemMatch("tags", Filters.regex("value", it, "i")) - }) - } - - val sensitiveLevel = call.parameters["sensitive_level"]?.toIntOrNull() - if (sensitiveLevel != null) { - filters += Filters.lte("sensitive_level", sensitiveLevel) - } - } - - val tags = collection.aggregate(Aggregates.match(Filters.and(filter))).toList().flatMap { - it.toPic().tags - }.map { - it.value - }.distinct().let { - it - existTags - }.let { - if (random) { - it.shuffled() - } else { - it - } - }.take(count) - - call.respondApi { - jsonObjectOf( - "tags" to tags - ) - } - } -} diff --git a/src/jvmMain/kotlin/blue/starry/stella/api/endpoints/GetSummary.kt b/src/jvmMain/kotlin/blue/starry/stella/api/endpoints/GetSummary.kt deleted file mode 100644 index 492dcaed..00000000 --- a/src/jvmMain/kotlin/blue/starry/stella/api/endpoints/GetSummary.kt +++ /dev/null @@ -1,22 +0,0 @@ -package blue.starry.stella.api.endpoints - -import blue.starry.jsonkt.jsonObjectOf -import blue.starry.stella.api.respondApi -import blue.starry.stella.collection -import blue.starry.stella.mediaDirectory -import io.ktor.application.call -import io.ktor.routing.Route -import io.ktor.routing.get -import java.nio.file.Files - -@Suppress("BlockingMethodInNonBlockingContext") -fun Route.getSummary() { - get("/summary") { - call.respondApi { - jsonObjectOf( - "entries" to collection.countDocuments(), - "media" to Files.list(mediaDirectory).count() - ) - } - } -} diff --git a/src/jvmMain/kotlin/blue/starry/stella/api/endpoints/PatchSensitiveLevel.kt b/src/jvmMain/kotlin/blue/starry/stella/api/endpoints/PatchSensitiveLevel.kt deleted file mode 100644 index 63193c0f..00000000 --- a/src/jvmMain/kotlin/blue/starry/stella/api/endpoints/PatchSensitiveLevel.kt +++ /dev/null @@ -1,61 +0,0 @@ -package blue.starry.stella.api.endpoints - -import blue.starry.stella.api.respondApi -import blue.starry.stella.api.respondApiError -import blue.starry.stella.api.serialize -import blue.starry.stella.api.toPic -import blue.starry.stella.collection -import blue.starry.stella.logger -import com.mongodb.client.model.Filters -import com.mongodb.client.model.Updates -import io.ktor.application.call -import io.ktor.features.origin -import io.ktor.http.HttpStatusCode -import io.ktor.locations.Location -import io.ktor.locations.patch -import io.ktor.request.receiveParameters -import io.ktor.routing.Route -import org.bson.types.ObjectId -import java.util.* - -@Location("/edit/{id}/sensitive_level") -data class EditSensitiveLevel(val id: String) - -fun Route.patchSensitiveLevel() { - patch { (id) -> - val level = call.receiveParameters()["sensitive_level"]?.toIntOrNull() - if (level !in 0..3) { - call.respondApiError(HttpStatusCode.BadRequest) { - "Essential \"sensitive_level\" is invalid or not present." - } - - return@patch - } - - if (collection.countDocuments(Filters.eq("_id", ObjectId(id))) == 0L) { - call.respondApiError(HttpStatusCode.NotFound) { - "Specified entry is not found." - } - - return@patch - } - - collection.updateOne( - Filters.eq("_id", ObjectId(id)), - Updates.combine( - Updates.set("sensitive_level", level), - Updates.set("timestamp.manual_updated", Calendar.getInstance().timeInMillis) - ) - ) - - val entry = collection.findOne(Filters.eq("_id", ObjectId(id)))!! - call.respondApi { - entry.serialize() - } - - logger.info { - val pic = entry.toPic() - "${pic.url} のエントリが更新されました。sensitive_level が $level に変更されました。(${call.request.origin.remoteHost})" - } - } -} diff --git a/src/jvmMain/kotlin/blue/starry/stella/api/endpoints/PutRefresh.kt b/src/jvmMain/kotlin/blue/starry/stella/api/endpoints/PutRefresh.kt deleted file mode 100644 index 2b679039..00000000 --- a/src/jvmMain/kotlin/blue/starry/stella/api/endpoints/PutRefresh.kt +++ /dev/null @@ -1,43 +0,0 @@ -package blue.starry.stella.api.endpoints - -import blue.starry.stella.api.respondApi -import blue.starry.stella.api.respondApiError -import blue.starry.stella.api.serialize -import blue.starry.stella.api.toPic -import blue.starry.stella.collection -import blue.starry.stella.worker.MediaRegister -import com.mongodb.client.model.Filters -import io.ktor.application.call -import io.ktor.http.HttpStatusCode -import io.ktor.locations.Location -import io.ktor.locations.put -import io.ktor.routing.Route -import org.bson.types.ObjectId - -@Location("/refresh/{id}") -data class PutRefresh(val id: String) - -fun Route.putRefresh() { - put { (id) -> - val entry = collection.findOne(Filters.eq("_id", ObjectId(id)))?.toPic() - if (entry == null) { - call.respondApiError(HttpStatusCode.NotFound) { - "Specified entry is not found." - } - - return@put - } - - if (!MediaRegister.registerByUrl(entry.url, null, false)) { - call.respondApiError { - "Unknown error occurred." - } - } else { - val newEntry = collection.findOne(Filters.eq("_id", ObjectId(id)))!! - - call.respondApi { - newEntry.serialize() - } - } - } -} diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/Common.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/Common.kt new file mode 100644 index 00000000..df6a47f1 --- /dev/null +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/Common.kt @@ -0,0 +1,32 @@ +package blue.starry.stella.endpoints + +import blue.starry.stella.models.ApiResponseModel +import io.ktor.application.* +import io.ktor.http.* +import io.ktor.response.* + +suspend fun ApplicationCall.respondApiResponse(result: Any) { + respond( + ApiResponseModel( + success = true, + result = result, + error = null + ) + ) +} + +suspend fun ApplicationCall.respondApiError( + code: HttpStatusCode = HttpStatusCode.InternalServerError, + block: () -> String +) { + respond( + ApiResponseModel( + success = true, + result = null, + error = ApiResponseModel.Error( + code = code.value, + message = block() + ) + ) + ) +} diff --git a/src/jvmMain/kotlin/blue/starry/stella/api/endpoints/Media.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetMedia.kt similarity index 70% rename from src/jvmMain/kotlin/blue/starry/stella/api/endpoints/Media.kt rename to src/jvmMain/kotlin/blue/starry/stella/endpoints/GetMedia.kt index 1cd918b5..31985142 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/api/endpoints/Media.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetMedia.kt @@ -1,4 +1,4 @@ -package blue.starry.stella.api.endpoints +package blue.starry.stella.endpoints import blue.starry.stella.mediaDirectory import io.ktor.application.* @@ -7,10 +7,10 @@ import io.ktor.response.* import io.ktor.routing.* @Location("/media/{filename}") -data class Media(val filename: String) +data class GetMedia(val filename: String) fun Route.getMedia() { - get { (filename) -> + get { (filename) -> call.respondFile(mediaDirectory.toFile(), filename) } } diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQuery.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQuery.kt new file mode 100644 index 00000000..ae17ea85 --- /dev/null +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQuery.kt @@ -0,0 +1,77 @@ +package blue.starry.stella.endpoints + +import blue.starry.stella.models.PicModel +import blue.starry.stella.worker.StellaMongoDBPicCollection +import io.ktor.application.* +import io.ktor.locations.* +import io.ktor.request.* +import io.ktor.routing.* +import kotlinx.coroutines.flow.flattenConcat +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList + +@Location("/query") +data class GetQuery( + val title: String? = null, + val description: String? = null, + val tags: String? = null, + val author: String? = null, + val platform: String? = null, + val user: String? = null, + val sensitive_levels: String? = null, + val created_since: String? = null, + val created_until: String? = null, + val added_since: String? = null, + val added_until: String? = null, + val updated_since: String? = null, + val updated_until: String? = null, + val ext: String? = null, + val min_rating: Int? = null, + val max_rating: Int? = null, + val min_like: Int? = null, + val max_like: Int? = null, + val min_bookmark: Int? = null, + val max_bookmark: Int? = null, + val min_view: Int? = null, + val max_view: Int? = null, + val min_retweet: Int? = null, + val max_retweet: Int? = null, + val min_reply: Int? = null, + val max_reply: Int? = null, + val sort: String? = null, + val page: Int = 0, + val count: Int = 25 +) + +fun Route.getQuery() { + get { param -> + val filters = flowOf( + GetQueryFilters.title(param.title), + GetQueryFilters.description(param.description), + GetQueryFilters.tags(param.tags), + GetQueryFilters.platform(param.platform), + GetQueryFilters.author(param.author), + GetQueryFilters.sensitiveLevel(param.sensitive_levels, call.request.header("X-Local-Access") == "1"), + GetQueryFilters.created(param.created_since, param.created_until), + GetQueryFilters.added(param.added_since, param.added_until), + GetQueryFilters.updated(param.updated_since, param.updated_until), + GetQueryFilters.extenstion(param.ext), + GetQueryFilters.rating(param.min_rating, param.max_rating), + GetQueryFilters.like(param.min_like, param.max_like), + GetQueryFilters.bookmark(param.min_bookmark, param.max_bookmark), + GetQueryFilters.view(param.min_view, param.max_view), + GetQueryFilters.retweet(param.min_retweet, param.max_retweet), + GetQueryFilters.reply(param.min_reply, param.max_reply) + ).flattenConcat().toList() + + val pipeline = flowOf( + GetQueryPipelines.sort(param.sort, param.count), + GetQueryPipelines.applyFilters(filters), + GetQueryPipelines.limit(param.sort, param.page, param.count) + ).flattenConcat().toList() + + call.respondApiResponse( + StellaMongoDBPicCollection.aggregate(pipeline).toList() + ) + } +} diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryFilters.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryFilters.kt new file mode 100644 index 00000000..c606426e --- /dev/null +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryFilters.kt @@ -0,0 +1,279 @@ +package blue.starry.stella.endpoints + +import blue.starry.stella.models.FileExtension +import blue.starry.stella.models.ImagePlatform +import blue.starry.stella.models.PicModel +import kotlinx.coroutines.flow.flow +import org.litote.kmongo.* +import java.time.LocalDate + +object GetQueryFilters { + fun title(value: String?) = flow { + if (!value.isNullOrBlank()) { + emit( + PicModel::title.regex(value, "im") + ) + } + } + + fun description(value: String?) = flow { + if (!value.isNullOrBlank()) { + emit( + PicModel::description.regex(value, "im") + ) + } + } + + fun tags(value: String?) = flow { + if (!value.isNullOrBlank()) { + emit( + or( + value.split(",") + .map { + PicModel::tags.elemMatch(PicModel.Tag::value.regex(it.trim(), "i")) + } + ) + ) + } + } + + fun platform(value: String?) = flow { + val platform = value?.toImagePlatform() + if (platform != null) { + emit( + PicModel::platform eq platform.name + ) + } + } + + private fun String.toImagePlatform(): ImagePlatform? { + return ImagePlatform.values().find { equals(it.name, true) } + } + + fun author(value: String?) = flow { + if (!value.isNullOrBlank()) { + emit( + or( + (PicModel::author / PicModel.Author::name).regex(value, "im"), + (PicModel::author / PicModel.Author::username).regex(value, "im") + ) + ) + } + } + + fun user(value: String?) = flow { + if (!value.isNullOrBlank()) { + emit( + PicModel::user eq value + ) + } + } + + fun sensitiveLevel(value: String?, trusted: Boolean) = flow { + if (trusted) { + val levels = value?.split(",")?.mapNotNull { it.toIntOrNull() }.orEmpty() + if (levels.isNotEmpty()) { + emit( + PicModel::sensitive_level.`in`(levels) + ) + } + } else { + emit( + PicModel::sensitive_level eq 0 + ) + } + } + + fun created(since: String?, until: String?) = flow { + if (since != null) { + runCatching { + LocalDate.parse(since) + }.onSuccess { d -> + emit( + PicModel::timestamp / PicModel.Timestamp::created gte d.toEpochDay() + ) + } + } + + if (until != null) { + runCatching { + LocalDate.parse(until) + }.onSuccess { d -> + emit( + PicModel::timestamp / PicModel.Timestamp::created lte d.toEpochDay() + ) + } + } + } + + fun added(since: String?, until: String?) = flow { + if (since != null) { + runCatching { + LocalDate.parse(since) + }.onSuccess { d -> + emit( + PicModel::timestamp / PicModel.Timestamp::added gte d.toEpochDay() + ) + } + } + + if (until != null) { + runCatching { + LocalDate.parse(until) + }.onSuccess { d -> + emit( + PicModel::timestamp / PicModel.Timestamp::added lte d.toEpochDay() + ) + } + } + } + + fun updated(since: String?, until: String?) = flow { + if (since != null) { + runCatching { + LocalDate.parse(since) + }.onSuccess { d -> + emit( + PicModel::timestamp / PicModel.Timestamp::manual_updated gte d.toEpochDay() + ) + } + } + + if (until != null) { + runCatching { + LocalDate.parse(until) + }.onSuccess { d -> + emit( + PicModel::timestamp / PicModel.Timestamp::manual_updated lte d.toEpochDay() + ) + } + } + } + + fun extenstion(value: String?) = flow { + val extension = value?.toFileExtension() + if (extension != null) { + emit( + or(extension.exts.map { PicModel::media.elemMatch(PicModel.Media::ext eq it) }) + ) + } + } + + private fun String.toFileExtension(): FileExtension? { + return FileExtension.values().find { equals(it.text, true) } + } + + fun rating(min: Int?, max: Int?) = flow { + if (min != null) { + emit( + expr("{\$gte: [\$divide: [\"\$rating.score\", \"\$rating.count\"], $min]}") + ) + } + + if (max != null) { + emit( + expr("{\$lte: [\$divide: [\"\$rating.score\", \"\$rating.count\"], $max]}") + ) + } + } + + fun like(min: Int?, max: Int?) = flow { + if (min != null) { + emit( + not(PicModel::popularity / PicModel.Popularity::like eq null) + ) + emit( + PicModel::popularity / PicModel.Popularity::like gte min + ) + } + + if (max != null) { + emit( + not(PicModel::popularity / PicModel.Popularity::like eq null) + ) + emit( + PicModel::popularity / PicModel.Popularity::like lte min + ) + } + } + + fun bookmark(min: Int?, max: Int?) = flow { + if (min != null) { + emit( + not(PicModel::popularity / PicModel.Popularity::bookmark eq null) + ) + emit( + PicModel::popularity / PicModel.Popularity::bookmark gte min + ) + } + + if (max != null) { + emit( + not(PicModel::popularity / PicModel.Popularity::bookmark eq null) + ) + emit( + PicModel::popularity / PicModel.Popularity::bookmark lte min + ) + } + } + + fun view(min: Int?, max: Int?) = flow { + if (min != null) { + emit( + not(PicModel::popularity / PicModel.Popularity::view eq null) + ) + emit( + PicModel::popularity / PicModel.Popularity::view gte min + ) + } + + if (max != null) { + emit( + not(PicModel::popularity / PicModel.Popularity::view eq null) + ) + emit( + PicModel::popularity / PicModel.Popularity::view lte min + ) + } + } + + fun retweet(min: Int?, max: Int?) = flow { + if (min != null) { + emit( + not(PicModel::popularity / PicModel.Popularity::retweet eq null) + ) + emit( + PicModel::popularity / PicModel.Popularity::retweet gte min + ) + } + + if (max != null) { + emit( + not(PicModel::popularity / PicModel.Popularity::retweet eq null) + ) + emit( + PicModel::popularity / PicModel.Popularity::retweet lte min + ) + } + } + + fun reply(min: Int?, max: Int?) = flow { + if (min != null) { + emit( + not(PicModel::popularity / PicModel.Popularity::reply eq null) + ) + emit( + PicModel::popularity / PicModel.Popularity::reply gte min + ) + } + + if (max != null) { + emit( + not(PicModel::popularity / PicModel.Popularity::reply eq null) + ) + emit( + PicModel::popularity / PicModel.Popularity::reply lte min + ) + } + } +} diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryPipelines.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryPipelines.kt new file mode 100644 index 00000000..8ca29426 --- /dev/null +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryPipelines.kt @@ -0,0 +1,192 @@ +package blue.starry.stella.endpoints + +import blue.starry.stella.models.PicModel +import blue.starry.stella.models.SortOrder +import kotlinx.coroutines.flow.flow +import org.bson.conversions.Bson +import org.litote.kmongo.* + +object GetQueryPipelines { + fun sort(value: String?, count: Int) = flow { + when (value?.toSortOrder() ?: SortOrder.ManualUpdatedDescending) { + SortOrder.AddedDescending -> { + emit( + sort(descending(PicModel::timestamp / PicModel.Timestamp::added)) + ) + } + SortOrder.AddedAscending -> { + emit( + sort(ascending(PicModel::timestamp / PicModel.Timestamp::added)) + ) + } + SortOrder.CreatedDescending -> { + emit( + sort(descending(PicModel::timestamp / PicModel.Timestamp::created)) + ) + } + SortOrder.CreatedAscending -> { + emit( + sort(ascending(PicModel::timestamp / PicModel.Timestamp::created)) + ) + } + SortOrder.ManualUpdatedDescending -> { + emit( + sort(descending(PicModel::timestamp / PicModel.Timestamp::manual_updated)) + ) + } + SortOrder.ManualUpdatedAscending -> { + emit( + sort(ascending(PicModel::timestamp / PicModel.Timestamp::manual_updated)) + ) + } + SortOrder.AutoUpdatedDescending -> { + emit( + sort(descending(PicModel::timestamp / PicModel.Timestamp::auto_updated)) + ) + } + SortOrder.AutoUpdatedAscending -> { + emit( + sort(ascending(PicModel::timestamp / PicModel.Timestamp::auto_updated)) + ) + } + SortOrder.TitleDescending -> { + emit( + sort(descending(PicModel::title)) + ) + } + SortOrder.TitleAscending -> { + emit( + sort(ascending(PicModel::title)) + ) + } + SortOrder.AuthorDescending -> { + emit( + sort(descending(PicModel::author / PicModel.Author::name)) + ) + } + SortOrder.AuthorAscending -> { + emit( + sort(ascending(PicModel::author / PicModel.Author::name)) + ) + } + SortOrder.RatingDescending -> { + emit( + sort(descending(PicModel::rating / PicModel.Rating::score)) + ) + } + SortOrder.RatingAscending -> { + emit( + sort(ascending(PicModel::rating / PicModel.Rating::score)) + ) + } + SortOrder.LikeDescending -> { + emit( + match(not(PicModel::popularity / PicModel.Popularity::like eq null)) + ) + emit( + sort(descending(PicModel::popularity / PicModel.Popularity::like)) + ) + } + SortOrder.LikeAscending -> { + emit( + match(not(PicModel::popularity / PicModel.Popularity::like eq null)) + ) + emit( + sort(ascending(PicModel::popularity / PicModel.Popularity::like)) + ) + } + SortOrder.BookmarkDescending -> { + emit( + match(not(PicModel::popularity / PicModel.Popularity::bookmark eq null)) + ) + emit( + sort(descending(PicModel::popularity / PicModel.Popularity::bookmark)) + ) + } + SortOrder.BookmarkAscending -> { + emit( + match(not(PicModel::popularity / PicModel.Popularity::bookmark eq null)) + ) + emit( + sort(ascending(PicModel::popularity / PicModel.Popularity::bookmark)) + ) + } + SortOrder.ViewDescending -> { + emit( + match(not(PicModel::popularity / PicModel.Popularity::view eq null)) + ) + emit( + sort(descending(PicModel::popularity / PicModel.Popularity::view)) + ) + } + SortOrder.ViewAscending -> { + emit( + match(not(PicModel::popularity / PicModel.Popularity::view eq null)) + ) + emit( + sort(ascending(PicModel::popularity / PicModel.Popularity::view)) + ) + } + SortOrder.RetweetDescending -> { + emit( + match(not(PicModel::popularity / PicModel.Popularity::retweet eq null)) + ) + emit( + sort(descending(PicModel::popularity / PicModel.Popularity::retweet)) + ) + } + SortOrder.RetweetAscending -> { + emit( + match(not(PicModel::popularity / PicModel.Popularity::retweet eq null)) + ) + emit( + sort(ascending(PicModel::popularity / PicModel.Popularity::retweet)) + ) + } + SortOrder.ReplyDescending -> { + emit( + match(not(PicModel::popularity / PicModel.Popularity::reply eq null)) + ) + emit( + sort(descending(PicModel::popularity / PicModel.Popularity::reply)) + ) + } + SortOrder.ReplyAscending -> { + emit( + match(not(PicModel::popularity / PicModel.Popularity::reply eq null)) + ) + emit( + sort(ascending(PicModel::popularity / PicModel.Popularity::reply)) + ) + } + SortOrder.Random -> { + emit( + sample(count) + ) + } + } + } + + fun applyFilters(filters: List) = flow { + if (filters.isNotEmpty()) { + emit( + match(and(filters)) + ) + } + } + + fun limit(order: String?, page: Int, count: Int) = flow { + if (order?.toSortOrder() != SortOrder.Random) { + emit( + skip(page * count) + ) + emit( + limit(count) + ) + } + } + + private fun String.toSortOrder(): SortOrder? { + return SortOrder.values().find { equals(it.text, true) } + } +} diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTags.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTags.kt new file mode 100644 index 00000000..45050bca --- /dev/null +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTags.kt @@ -0,0 +1,71 @@ +package blue.starry.stella.endpoints + +import blue.starry.stella.models.PicModel +import blue.starry.stella.models.PicTagsModel +import blue.starry.stella.worker.StellaMongoDBPicCollection +import io.ktor.application.* +import io.ktor.locations.* +import io.ktor.routing.* +import org.litote.kmongo.* +import org.litote.kmongo.coroutine.aggregate + +@Location("/query/tags") +data class GetQueryTags( + val id: String? = null, + val name: String? = null, + val sensitive_level: Int? = null, + val count: Int = 10, + val random: Boolean = false +) + +fun Route.getQueryTags() { + get { param -> + val existingTags = param.id?.let { id -> + StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId())?.tags + }.orEmpty().map { + it.value + } + + val filters = buildList { + this += not(PicModel::tags.size(0)) + + if (param.name != null) { + this += PicModel::tags.elemMatch(PicModel.Tag::value.regex(param.name, "i")) + } + + if (existingTags.isNotEmpty()) { + this += or(existingTags.map { tag -> + PicModel::tags.elemMatch(PicModel.Tag::value.regex(tag, "i")) + }) + } + + if (param.sensitive_level != null) { + this += PicModel::sensitive_level lte param.sensitive_level + } + } + + val tags = StellaMongoDBPicCollection + .aggregate(match(and(filters))) + .toList() + .asSequence() + .flatMap { it.tags } + .map { it.value } + .distinct() + .filter { it !in existingTags } + .let { + if (param.random) { + it.shuffled() + } else { + it + } + } + .take(param.count) + .toList() + + call.respondApiResponse( + PicTagsModel( + tags = tags + ) + ) + } +} diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetSummary.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetSummary.kt new file mode 100644 index 00000000..e7871059 --- /dev/null +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetSummary.kt @@ -0,0 +1,24 @@ +package blue.starry.stella.endpoints + +import blue.starry.stella.mediaDirectory +import blue.starry.stella.models.PicSummaryModel +import blue.starry.stella.worker.StellaMongoDBPicCollection +import io.ktor.application.* +import io.ktor.routing.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.file.Files + +fun Route.getSummary() { + get("/summary") { + call.respondApiResponse( + PicSummaryModel( + entries = StellaMongoDBPicCollection.countDocuments(), + media = withContext(Dispatchers.IO) { + @Suppress("BlockingMethodInNonBlockingContext") + Files.list(mediaDirectory).count() + } + ) + ) + } +} diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchSensitiveLevel.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchSensitiveLevel.kt new file mode 100644 index 00000000..a53735ec --- /dev/null +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchSensitiveLevel.kt @@ -0,0 +1,55 @@ +package blue.starry.stella.endpoints + +import blue.starry.stella.logger +import blue.starry.stella.models.PicModel +import blue.starry.stella.worker.StellaMongoDBPicCollection +import com.mongodb.client.model.Updates +import io.ktor.application.* +import io.ktor.features.* +import io.ktor.http.* +import io.ktor.locations.* +import io.ktor.locations.patch +import io.ktor.request.* +import io.ktor.routing.Route +import org.litote.kmongo.combine +import org.litote.kmongo.eq +import org.litote.kmongo.setValue +import org.litote.kmongo.toId +import java.time.Instant +import java.util.* + +@Location("/edit/{id}/sensitive_level") +data class PatchEditSensitiveLevel(val id: String, val sensitive_level: Int) + +fun Route.patchSensitiveLevel() { + patch { param -> + if (param.sensitive_level !in 0..3) { + return@patch call.respondApiError(HttpStatusCode.BadRequest) { + "Essential \"sensitive_level\" is invalid or not present." + } + } + + if (StellaMongoDBPicCollection.countDocuments(PicModel::_id eq param.id.toId()) == 0L) { + return@patch call.respondApiError(HttpStatusCode.NotFound) { + "Specified entry is not found." + } + } + + StellaMongoDBPicCollection.updateOne( + PicModel::_id eq param.id.toId(), + combine( + setValue(PicModel::sensitive_level, param.sensitive_level), + Updates.set("timestamp.manual_updated", Instant.now().toEpochMilli()) + ) + ) + + val entry = StellaMongoDBPicCollection.findOne(PicModel::_id eq param.id.toId())!! + call.respondApiResponse { + entry + } + + logger.info { + "${entry.url} のエントリが更新されました。sensitive_level が ${param.sensitive_level} に変更されました。(${call.request.origin.remoteHost})" + } + } +} diff --git a/src/jvmMain/kotlin/blue/starry/stella/api/endpoints/EditTag.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutEditTag.kt similarity index 69% rename from src/jvmMain/kotlin/blue/starry/stella/api/endpoints/EditTag.kt rename to src/jvmMain/kotlin/blue/starry/stella/endpoints/PutEditTag.kt index f8ed1030..1bec5238 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/api/endpoints/EditTag.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutEditTag.kt @@ -1,11 +1,8 @@ -package blue.starry.stella.api.endpoints +package blue.starry.stella.endpoints -import blue.starry.stella.api.respondApi -import blue.starry.stella.api.respondApiError -import blue.starry.stella.api.serialize -import blue.starry.stella.api.toPic -import blue.starry.stella.collection import blue.starry.stella.logger +import blue.starry.stella.models.PicModel +import blue.starry.stella.worker.StellaMongoDBPicCollection import com.mongodb.client.model.Filters import com.mongodb.client.model.Updates import io.ktor.application.* @@ -15,13 +12,15 @@ import io.ktor.locations.* import io.ktor.request.* import io.ktor.routing.Route import org.bson.types.ObjectId +import org.litote.kmongo.eq +import org.litote.kmongo.toId import java.util.* @Location("/edit/{id}/tag") -data class EditTag(val id: String) +data class PutEditTag(val id: String) fun Route.putEditTag() { - put { (id) -> + put { (id) -> val tag = call.receiveParameters()["tag"] if (tag == null) { call.respondApiError(HttpStatusCode.BadRequest) { @@ -31,7 +30,7 @@ fun Route.putEditTag() { return@put } - val oldEntry = collection.findOne(Filters.eq("_id", ObjectId(id)))?.toPic() + val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId()) if (oldEntry == null) { call.respondApiError(HttpStatusCode.NotFound) { "Specified entry is not found." @@ -56,22 +55,21 @@ fun Route.putEditTag() { )), Updates.set("timestamp.manual_updated", Calendar.getInstance().timeInMillis) ) - collection.updateOne(Filters.eq("_id", ObjectId(id)), updates) + StellaMongoDBPicCollection.updateOne(PicModel::_id eq id.toId(), updates) - val entry = collection.findOne(Filters.eq("_id", ObjectId(id)))!! - call.respondApi { - entry.serialize() + val entry = StellaMongoDBPicCollection.findOne(Filters.eq("_id", ObjectId(id)))!! + call.respondApiResponse { + entry } logger.info { - val pic = entry.toPic() - "${pic.url} のエントリが更新されました。「$tag」が追加されました。(${call.request.origin.remoteHost})" + "${entry.url} のエントリが更新されました。「$tag」が追加されました。(${call.request.origin.remoteHost})" } } } fun Route.deleteEditTag() { - delete { (id) -> + delete { (id) -> val tag = call.receiveParameters()["tag"] if (tag == null) { call.respondApiError(HttpStatusCode.BadRequest) { @@ -81,7 +79,7 @@ fun Route.deleteEditTag() { return@delete } - val oldEntry = collection.findOne(Filters.eq("_id", ObjectId(id)))?.toPic() + val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId()) if (oldEntry == null) { call.respondApiError(HttpStatusCode.NotFound) { "Specified entry is not found." @@ -118,16 +116,15 @@ fun Route.deleteEditTag() { Updates.set("tags", tags), Updates.set("timestamp.manual_updated", Calendar.getInstance().timeInMillis) ) - collection.updateOne(Filters.eq("_id", ObjectId(id)), updates) + StellaMongoDBPicCollection.updateOne(PicModel::_id eq id.toId(), updates) - val entry = collection.findOne(Filters.eq("_id", ObjectId(id)))!! - call.respondApi { - entry.serialize() + val entry = StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId())!! + call.respondApiResponse { + entry } logger.info { - val pic = entry.toPic() - "${pic.url} のエントリが更新されました。「$tag」が削除されました。(${call.request.origin.remoteHost})" + "${entry.url} のエントリが更新されました。「$tag」が削除されました。(${call.request.origin.remoteHost})" } } } diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutRefresh.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutRefresh.kt new file mode 100644 index 00000000..a94162a9 --- /dev/null +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutRefresh.kt @@ -0,0 +1,36 @@ +package blue.starry.stella.endpoints + +import blue.starry.stella.models.PicModel +import blue.starry.stella.worker.MediaRegister +import blue.starry.stella.worker.StellaMongoDBPicCollection +import io.ktor.application.* +import io.ktor.http.* +import io.ktor.locations.* +import io.ktor.locations.put +import io.ktor.routing.Route +import org.litote.kmongo.eq +import org.litote.kmongo.toId + +@Location("/refresh/{id}") +data class PutRefresh(val id: String) + +fun Route.putRefresh() { + put { (id) -> + val entry = StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId()) + ?: return@put call.respondApiError(HttpStatusCode.NotFound) { + "Specified entry is not found." + } + + if (!MediaRegister.registerByUrl(entry.url, null, false)) { + call.respondApiError { + "Unknown error occurred." + } + } else { + val newEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId())!! + + call.respondApiResponse { + newEntry + } + } + } +} diff --git a/src/jvmMain/kotlin/blue/starry/stella/models/ApiResponseModel.kt b/src/jvmMain/kotlin/blue/starry/stella/models/ApiResponseModel.kt new file mode 100644 index 00000000..d71846de --- /dev/null +++ b/src/jvmMain/kotlin/blue/starry/stella/models/ApiResponseModel.kt @@ -0,0 +1,17 @@ +package blue.starry.stella.models + +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable + +@Serializable +data class ApiResponseModel( + val success: Boolean, + @Contextual val result: Any?, + val error: Error? +) { + @Serializable + data class Error( + val code: Int, + val message: String + ) +} diff --git a/src/commonMain/kotlin/blue.starry.stella/common/FileExtension.kt b/src/jvmMain/kotlin/blue/starry/stella/models/FileExtension.kt similarity index 66% rename from src/commonMain/kotlin/blue.starry.stella/common/FileExtension.kt rename to src/jvmMain/kotlin/blue/starry/stella/models/FileExtension.kt index 803a19d7..6aceae65 100644 --- a/src/commonMain/kotlin/blue.starry.stella/common/FileExtension.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/models/FileExtension.kt @@ -1,4 +1,4 @@ -package blue.starry.stella.common +package blue.starry.stella.models enum class FileExtension(internal val text: String, vararg val exts: String) { Image("image", "jpg", "jpeg", "png"), @@ -10,7 +10,3 @@ enum class FileExtension(internal val text: String, vararg val exts: String) { MP4("mp4", "mp4"), M3U8("m3u8", "m3u8") } - -fun String.toFileExtension(): FileExtension? { - return FileExtension.values().find { equals(it.text, true) } -} diff --git a/src/jvmMain/kotlin/blue/starry/stella/models/ImagePlatform.kt b/src/jvmMain/kotlin/blue/starry/stella/models/ImagePlatform.kt new file mode 100644 index 00000000..7aac7de4 --- /dev/null +++ b/src/jvmMain/kotlin/blue/starry/stella/models/ImagePlatform.kt @@ -0,0 +1,5 @@ +package blue.starry.stella.models + +enum class ImagePlatform { + Twitter, Nijie, Pixiv +} diff --git a/src/jvmMain/kotlin/blue/starry/stella/models/PicModel.kt b/src/jvmMain/kotlin/blue/starry/stella/models/PicModel.kt new file mode 100644 index 00000000..e0aed06a --- /dev/null +++ b/src/jvmMain/kotlin/blue/starry/stella/models/PicModel.kt @@ -0,0 +1,70 @@ +package blue.starry.stella.models + +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import org.litote.kmongo.Id + +@Serializable +data class PicModel( + @Contextual val _id: Id, + val title: String, + val description: String, + val url: String, + val tags: List, + val user: String?, + + val platform: String, + val sensitive_level: Int, + + val timestamp: Timestamp, + val author: Author, + val media: List, + + val rating: Rating, + val popularity: Popularity +) { + @Serializable + data class Tag( + val value: String, + val user: String?, + val locked: Boolean + ) + + @Serializable + data class Timestamp( + val created: Long, + val added: Long, + val manual_updated: Long, + val auto_updated: Long + ) + + @Serializable + data class Author( + val name: String, + val username: String?, + val url: String + ) + + @Serializable + data class Media( + val index: Int, + val filename: String, + val original: String, + val ext: String + ) + + @Serializable + data class Rating( + val count: Int, + val score: Int + ) + + @Serializable + data class Popularity( + val like: Int?, + val bookmark: Int?, + val view: Int?, + val retweet: Int?, + val reply: Int? + ) +} diff --git a/src/jvmMain/kotlin/blue/starry/stella/models/PicSummaryModel.kt b/src/jvmMain/kotlin/blue/starry/stella/models/PicSummaryModel.kt new file mode 100644 index 00000000..3ce59945 --- /dev/null +++ b/src/jvmMain/kotlin/blue/starry/stella/models/PicSummaryModel.kt @@ -0,0 +1,9 @@ +package blue.starry.stella.models + +import kotlinx.serialization.Serializable + +@Serializable +data class PicSummaryModel( + val entries: Long, + val media: Long +) diff --git a/src/jvmMain/kotlin/blue/starry/stella/models/PicTagReplaceTableModel.kt b/src/jvmMain/kotlin/blue/starry/stella/models/PicTagReplaceTableModel.kt new file mode 100644 index 00000000..77a233d7 --- /dev/null +++ b/src/jvmMain/kotlin/blue/starry/stella/models/PicTagReplaceTableModel.kt @@ -0,0 +1,9 @@ +package blue.starry.stella.models + +import kotlinx.serialization.Serializable + +@Serializable +data class PicTagReplaceTableModel( + val from: String, + val to: String +) diff --git a/src/jvmMain/kotlin/blue/starry/stella/models/PicTagsModel.kt b/src/jvmMain/kotlin/blue/starry/stella/models/PicTagsModel.kt new file mode 100644 index 00000000..c6a2e727 --- /dev/null +++ b/src/jvmMain/kotlin/blue/starry/stella/models/PicTagsModel.kt @@ -0,0 +1,8 @@ +package blue.starry.stella.models + +import kotlinx.serialization.Serializable + +@Serializable +data class PicTagsModel( + val tags: List +) diff --git a/src/commonMain/kotlin/blue.starry.stella/common/SortOrder.kt b/src/jvmMain/kotlin/blue/starry/stella/models/SortOrder.kt similarity index 88% rename from src/commonMain/kotlin/blue.starry.stella/common/SortOrder.kt rename to src/jvmMain/kotlin/blue/starry/stella/models/SortOrder.kt index 9119e77c..c831f5d4 100644 --- a/src/commonMain/kotlin/blue.starry.stella/common/SortOrder.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/models/SortOrder.kt @@ -1,4 +1,4 @@ -package blue.starry.stella.common +package blue.starry.stella.models enum class SortOrder(internal val text: String) { AddedDescending("added_descending"), AddedAscending("added_ascending"), @@ -27,7 +27,3 @@ enum class SortOrder(internal val text: String) { Random("random"); } - -fun String.toSortOrder(): SortOrder? { - return SortOrder.values().find { equals(it.text, true) } -} diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/Client.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/Client.kt new file mode 100644 index 00000000..8a22b64a --- /dev/null +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/Client.kt @@ -0,0 +1,79 @@ +package blue.starry.stella.worker + +import blue.starry.penicillin.PenicillinClient +import blue.starry.penicillin.core.session.config.account +import blue.starry.penicillin.core.session.config.application +import blue.starry.penicillin.core.session.config.httpClient +import blue.starry.penicillin.core.session.config.token +import blue.starry.stella.Env +import blue.starry.stella.models.PicModel +import blue.starry.stella.models.PicTagReplaceTableModel +import blue.starry.stella.worker.platform.NijieClient +import blue.starry.stella.worker.platform.PixivClient +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.features.cookies.* +import org.litote.kmongo.coroutine.coroutine +import org.litote.kmongo.reactivestreams.KMongo + +val StellaHttpClient by lazy { + HttpClient(CIO) { + install(HttpCookies) + } +} + +val StellaPixivClient by lazy { + val (email, password) = Env.PIXIV_EMAIL to Env.PIXIV_PASSWORD + if (email == null || password == null) { + return@lazy null + } + + PixivClient(email, password) +} + +val StellaTwitterClient by lazy { + val (ck, cs) = Env.TWITTER_CK to Env.TWITTER_CS + val (at, ats) = Env.TWITTER_AT to Env.TWITTER_ATS + if(ck == null || cs == null || at == null || ats == null) { + return@lazy null + } + + PenicillinClient { + account { + application(ck, cs) + token(at, ats) + } + httpClient(StellaHttpClient) + } +} + +val StellaNijieClient by lazy { + val (email, password) = Env.NIJIE_EMAIL to Env.NIJIE_PASSWORD + if (email == null || password == null) { + return@lazy null + } + + NijieClient(email, password) +} + +val StellaMongoDBClient by lazy { + val (user, password) = Env.DB_USER to Env.DB_PASSWORD + + if (user != null && password != null) { + KMongo.createClient("mongodb://$user:$password@${Env.DB_HOST}:${Env.DB_PORT}") + } else { + KMongo.createClient("mongodb://${Env.DB_HOST}:${Env.DB_PORT}") + }.coroutine +} + +val StellaMongoDBDatabase by lazy { + StellaMongoDBClient.getDatabase(Env.DB_NAME) +} + +val StellaMongoDBPicCollection by lazy { + StellaMongoDBDatabase.getCollection("Pic") +} + +val StellaMongoDBPicTagReplaceTableCollection by lazy { + StellaMongoDBDatabase.getCollection("PicTagReplaceTable") +} diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/MediaRegister.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/MediaRegister.kt index 87f7b6d4..c9ae520e 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/MediaRegister.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/MediaRegister.kt @@ -1,33 +1,28 @@ package blue.starry.stella.worker -import blue.starry.jsonkt.encodeToString -import blue.starry.jsonkt.jsonObjectOf -import blue.starry.stella.api.toPic -import blue.starry.stella.api.toTagReplaceTable -import blue.starry.stella.collection import blue.starry.stella.logger -import blue.starry.stella.tagReplaceTable +import blue.starry.stella.models.PicModel +import blue.starry.stella.models.PicTagReplaceTableModel import blue.starry.stella.worker.platform.NijieSourceProvider import blue.starry.stella.worker.platform.PixivSourceProvider import blue.starry.stella.worker.platform.TwitterSourceProvider -import com.mongodb.client.model.Filters -import com.mongodb.client.model.Updates -import org.bson.Document -import java.util.* +import org.litote.kmongo.coroutine.updateOne +import org.litote.kmongo.eq +import org.litote.kmongo.newId +import java.time.Instant object MediaRegister { suspend fun registerByUrl(url: String, user: String?, auto: Boolean): Boolean { return runCatching { - @Suppress("IMPLICIT_CAST_TO_ANY") when { "twitter.com" in url -> { - TwitterSourceProvider.fetch(url, user, auto) + TwitterSourceProvider.fetch(StellaTwitterClient ?: return false, url, user, auto) } "pixiv.net" in url -> { - PixivSourceProvider.enqueue(url) + PixivSourceProvider.enqueue(StellaPixivClient ?: return false, url) } "nijie.info" in url -> { - NijieSourceProvider.fetch(url, user, auto) + NijieSourceProvider.fetch(StellaNijieClient ?: return false, url, user, auto) } else -> return false } @@ -35,107 +30,80 @@ object MediaRegister { } suspend fun register(entry: Entry, auto: Boolean) { - val oldEntry = collection.findOne(Filters.eq("url", entry.url))?.toPic() - - val title = entry.title.replace("\r\n", " ").replace("\n", " ").replace("
", " ") - val description = entry.description.replace("\r\n", "
").replace("\n", "
") - if (oldEntry != null) { - val tags = entry.tags.map { - mapOf( - "value" to (tagReplaceTable.findOne(Filters.eq("from", it))?.toTagReplaceTable()?.to ?: it), - "user" to null, - "locked" to true + val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::url eq entry.url) + val newEntry = PicModel( + _id = oldEntry?._id ?: newId(), + title = entry.title.normalizeTitle(), + description = entry.description.normalizeDescription(), + url = entry.url, + tags = (entry.tags.map { + PicModel.Tag( + value = it.normalizeTag(), + user = null, + locked = true ) - } + oldEntry.tags.filter { !it.locked && it.value !in entry.tags }.map { - mapOf( - "value" to (tagReplaceTable.findOne(Filters.eq("from", it.value))?.toTagReplaceTable()?.to ?: it.value), - "user" to it.user, - "locked" to it.locked + } + oldEntry?.tags?.map { + it.copy( + value = it.value.normalizeTag() ) - } - - collection.updateOne( - Filters.eq("url", entry.url), - Updates.combine( - Updates.set("title", title), - Updates.set("description", description), - Updates.set("tags", tags.distinctBy { it["value"] }), - Updates.set("user", entry.user ?: oldEntry.user), - Updates.set("sensitive_level", maxOf(entry.sensitiveLevel, oldEntry.sensitiveLevel)), - Updates.set("timestamp.${if (auto) "auto" else "manual"}_updated", Date().time), - Updates.set("author.name", entry.author.name), - Updates.set("author.url", entry.author.url), - Updates.set("author.username", entry.author.username), - Updates.set("media", entry.media.map { - mapOf( - "index" to it.index, - "filename" to it.filename, - "original" to it.original, - "ext" to it.ext - ) - }), - Updates.set("popularity.like", entry.popularity.like), - Updates.set("popularity.bookmark", entry.popularity.bookmark), - Updates.set("popularity.view", entry.popularity.view), - Updates.set("popularity.retweet", entry.popularity.retweet), - Updates.set("popularity.reply", entry.popularity.reply) + }.orEmpty()).distinctBy { it.value }, + user = entry.user ?: oldEntry?.user, + platform = entry.platform, + sensitive_level = maxOf(entry.sensitiveLevel, oldEntry?.sensitive_level ?: 0), + timestamp = PicModel.Timestamp( + created = entry.created, + added = oldEntry?.timestamp?.added ?: Instant.now().toEpochMilli(), + auto_updated = if (auto) Instant.now().toEpochMilli() else (oldEntry?.timestamp?.auto_updated ?: Instant.now().toEpochMilli()), + manual_updated = if (!auto) Instant.now().toEpochMilli() else (oldEntry?.timestamp?.manual_updated ?: Instant.now().toEpochMilli()) + ), + author = PicModel.Author( + name = entry.author.name, + url = entry.author.url, + username = entry.author.username + ), + media = entry.media.map { + PicModel.Media( + index = it.index, + filename = it.filename, + original = it.filename, + ext = it.ext ) + }, + rating = PicModel.Rating( + count = oldEntry?.rating?.count ?: 0, + score = oldEntry?.rating?.score ?: 0 + ), + popularity = PicModel.Popularity( + like = entry.popularity.like, + bookmark = entry.popularity.bookmark, + view = entry.popularity.view, + retweet = entry.popularity.retweet, + reply = entry.popularity.reply ) + ) - if (!auto) { - logger.info { "${entry.author.name} (${entry.platform}): \"${entry.title}\" (${entry.url}) を更新しました。" } - } + if (oldEntry != null) { + StellaMongoDBPicCollection.updateOne(newEntry) + logger.info { "\"${entry.title}\" (${entry.url}) を更新しました。" } } else { - collection.insertOne(Document.parse(jsonObjectOf( - "title" to title, - "description" to description, - "url" to entry.url, - "tags" to entry.tags.map { - mapOf( - "value" to (tagReplaceTable.findOne(Filters.eq("from", it))?.toTagReplaceTable()?.to ?: it), - "user" to null, - "locked" to true - ) - }.distinctBy { it["value"] }, - "user" to entry.user, + StellaMongoDBPicCollection.insertOne(newEntry) + logger.info { "\"${entry.title}\" (${entry.url}) を追加しました。" } + } + } - "platform" to entry.platform, - "sensitive_level" to entry.sensitiveLevel, + private fun String.normalizeTitle(): String { + return replace("\r\n", " ") + .replace("\n", " ") + .replace("
", " ") + } - "timestamp" to mapOf( - "created" to entry.created, - "added" to Calendar.getInstance().timeInMillis, - "auto_updated" to Calendar.getInstance().timeInMillis, - "manual_updated" to Calendar.getInstance().timeInMillis - ), - "author" to mapOf( - "name" to entry.author.name, - "url" to entry.author.url, - "username" to entry.author.username - ), - "media" to entry.media.map { - mapOf( - "index" to it.index, - "filename" to it.filename, - "original" to it.original, - "ext" to it.ext - ) - }, - "rating" to mapOf( - "count" to 0, - "score" to 0 - ), - "popularity" to mapOf( - "like" to entry.popularity.like, - "bookmark" to entry.popularity.bookmark, - "view" to entry.popularity.view, - "retweet" to entry.popularity.retweet, - "reply" to entry.popularity.reply - ) - ).encodeToString())) + private fun String.normalizeDescription(): String { + return replace("\r\n", "
") + .replace("\n", "
") + } - logger.info { "${entry.author.name} (${entry.platform}): \"${entry.title}\" (${entry.url}) を追加しました。" } - } + private suspend fun String.normalizeTag(): String { + return StellaMongoDBPicTagReplaceTableCollection.findOne(PicTagReplaceTableModel::from eq this)?.to ?: this } data class Entry(val title: String, val description: String, val url: String, val tags: List, val user: String?, val platform: String, val sensitiveLevel: Int, val created: Long, val author: Author, val media: List, val popularity: Popularity) { diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/MissingMediaRefetchWorker.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/MissingMediaRefetchWorker.kt index e9857c98..55508115 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/MissingMediaRefetchWorker.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/MissingMediaRefetchWorker.kt @@ -2,23 +2,21 @@ package blue.starry.stella.worker import blue.starry.penicillin.core.exceptions.PenicillinTwitterApiException import blue.starry.penicillin.core.exceptions.TwitterApiError -import blue.starry.stella.api.toPic -import blue.starry.stella.collection import blue.starry.stella.logger import blue.starry.stella.mediaDirectory import com.mongodb.client.model.Filters -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import java.nio.file.Files import kotlin.time.minutes object MissingMediaRefetchWorker { - fun start() { - GlobalScope.launch { - while (true) { + suspend fun start(): Unit = coroutineScope { + launch { + while (isActive) { try { check() + } catch (e: CancellationException) { + break } catch (e: Throwable) { logger.error(e) { "MissingMediaRefetchWorker で例外が発生しました。" } } @@ -29,8 +27,7 @@ object MissingMediaRefetchWorker { } private suspend fun check() { - for (document in collection.find().toList()) { - val pic = document.toPic() + for (pic in StellaMongoDBPicCollection.find().toList()) { if (pic.media.all { Files.exists(mediaDirectory.resolve(it.filename)) }) { continue } @@ -41,14 +38,16 @@ object MissingMediaRefetchWorker { MediaRegister.registerByUrl(pic.url, pic.user, true) logger.info { "エントリー: \"${pic.title}\" (${pic.url}) を更新しました。" } + } catch (e: CancellationException) { + return } catch (e: PenicillinTwitterApiException) { when (e.error) { TwitterApiError.NoStatusFound, TwitterApiError.ResourceNotFound -> { - collection.deleteOne(Filters.eq("url", pic.url)) + StellaMongoDBPicCollection.deleteOne(Filters.eq("url", pic.url)) logger.warn { "\"${pic.title}\" (${pic.url}) は削除されているため, エントリを削除しました。" } } TwitterApiError.SuspendedUser -> { - collection.deleteOne(Filters.eq("url", pic.url)) + StellaMongoDBPicCollection.deleteOne(Filters.eq("url", pic.url)) logger.warn { "\"${pic.title}\" (${pic.url}) は作者が凍結されているため, エントリを削除しました。" } } TwitterApiError.CannotSeeProtectedStatus -> { diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/RefreshWorker.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/RefreshWorker.kt index 5601efe8..b3d13c81 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/RefreshWorker.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/RefreshWorker.kt @@ -1,23 +1,25 @@ package blue.starry.stella.worker -import blue.starry.stella.Config -import blue.starry.stella.api.toPic -import blue.starry.stella.collection +import blue.starry.stella.Env import blue.starry.stella.logger -import com.mongodb.client.model.Filters -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import java.util.* +import blue.starry.stella.models.PicModel +import kotlinx.coroutines.* +import org.litote.kmongo.div +import org.litote.kmongo.eq +import org.litote.kmongo.lte +import org.litote.kmongo.or +import java.time.Instant import kotlin.time.minutes import kotlin.time.seconds object RefreshWorker { - fun start() { - GlobalScope.launch { - while (true) { + suspend fun start(): Unit = coroutineScope { + launch { + while (isActive) { try { check() + } catch (e: CancellationException) { + break } catch (e: Throwable) { logger.error(e) { "RefreshWorker で例外が発生しました。" } } @@ -30,17 +32,16 @@ object RefreshWorker { private suspend fun check() { delay(30.seconds) - val filter = Filters.or( - Filters.eq("timestamp.auto_updated", null), - Filters.lte("timestamp.auto_updated", Calendar.getInstance().timeInMillis - Config.AutoRefreshThreshold) + val filter = or( + PicModel::timestamp / PicModel.Timestamp::auto_updated eq null, + PicModel::timestamp / PicModel.Timestamp::auto_updated lte Instant.now().toEpochMilli() - Env.AUTO_REFRESH_THRESHOLD ) - collection.find(filter).limit(200).toList().forEach { document -> - val pic = document.toPic() + for (pic in StellaMongoDBPicCollection.find(filter).limit(200).toList()) { try { MediaRegister.registerByUrl(pic.url, pic.user, true) - - logger.info { "エントリー: \"${pic.title}\" (${pic.url}) を更新しました。" } + } catch (e: CancellationException) { + return } catch (e: Throwable) { logger.error(e) { "エントリー: \"${pic.title}\" (${pic.url}) の更新に失敗しました。" } } finally { diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieClient.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieClient.kt index 81f542a0..246a0ba2 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieClient.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieClient.kt @@ -2,9 +2,8 @@ package blue.starry.stella.worker.platform import blue.starry.jsonkt.parseObject import blue.starry.jsonkt.toJsonObject -import io.ktor.client.* -import io.ktor.client.engine.cio.* -import io.ktor.client.features.cookies.* +import blue.starry.stella.logger +import blue.starry.stella.worker.StellaHttpClient import io.ktor.client.request.* import io.ktor.client.request.forms.* import io.ktor.client.statement.* @@ -12,22 +11,21 @@ import io.ktor.http.* import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonPrimitive -import org.apache.commons.lang3.time.FastDateFormat import org.jsoup.Jsoup import java.io.File +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter import java.util.* -object NijieClient { - private val httpClient = HttpClient(CIO) { - install(HttpCookies) - } +class NijieClient(private val email: String, private val password: String) { var isLoggedIn = checkSession() private set private fun checkSession(): Boolean { return runCatching { runBlocking { - httpClient.get("https://nijie.info") { + StellaHttpClient.get("https://nijie.info") { setHeaders() }.execute { it.call.request.url.encodedPath == "/" @@ -41,13 +39,13 @@ object NijieClient { userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.80 Safari/537.36") } - suspend fun login(email: String, password: String) { + suspend fun login() { val parameters = Parameters.build { append("email", email) append("password", password) append("save", "on") - val jsoup = httpClient.get("https://nijie.info/login.php") { + val jsoup = StellaHttpClient.get("https://nijie.info/login.php") { setHeaders() }.let { Jsoup.parse(it) @@ -56,17 +54,18 @@ object NijieClient { append("url", jsoup.select("input[name=url]").attr("value")) } - httpClient.submitForm(parameters) { + StellaHttpClient.submitForm(parameters) { url("https://nijie.info/login_int.php") setHeaders() header(HttpHeaders.Referrer, "https://nijie.info/login.php") }.execute() isLoggedIn = true + logger.info { "Nijie にログインしました。" } } suspend fun bookmarks(page: Int = 1): List { - val html = httpClient.get("https://nijie.info/okiniiri.php?p=$page") { + val html = StellaHttpClient.get("https://nijie.info/okiniiri.php?p=$page") { setHeaders() } @@ -79,7 +78,7 @@ object NijieClient { } suspend fun deleteBookmark(id: String) { - val jsoup = httpClient.get("https://nijie.info/bookmark_edit.php?id=$id") { + val jsoup = StellaHttpClient.get("https://nijie.info/bookmark_edit.php?id=$id") { setHeaders() }.let { Jsoup.parse(it) @@ -91,20 +90,20 @@ object NijieClient { append(key, id) } - httpClient.submitForm(parameters) { + StellaHttpClient.submitForm(parameters) { url("https://nijie.info/bookmark_delete.php") setHeaders() }.execute() } - private val format = FastDateFormat.getInstance("EEE MMM d HH:mm:ss yyyy", Locale.ENGLISH) + private val formatter = DateTimeFormatter.ofPattern("EEE MMM d HH:mm:ss yyyy", Locale.ENGLISH) suspend fun picture(id: String): NijieModel.Picture { - val jsoup = httpClient.get("https://nijie.info/view.php?id=$id") { + val jsoup = StellaHttpClient.get("https://nijie.info/view.php?id=$id") { setHeaders() }.let { Jsoup.parse(it) } - val jsoup2 = httpClient.get("https://nijie.info/view_popup.php?id=$id") { + val jsoup2 = StellaHttpClient.get("https://nijie.info/view_popup.php?id=$id") { setHeaders() }.let { Jsoup.parse(it) @@ -118,7 +117,8 @@ object NijieClient { val reply = jsoup.getElementById("comment_list_js").childNodeSize() / 2 val view = viewCount(id) - return NijieModel.Picture(json.name, json.author.name, json.author.sameAs, format.parse(json.datePublished).time, images, json.description, "https://nijie.info/view.php?id=$id", id, tags, like, bookmark, reply, view) + return NijieModel.Picture(json.name, json.author.name, json.author.sameAs, LocalDateTime.parse(json.datePublished, formatter).atZone( + ZoneId.of("UTC")).toInstant().toEpochMilli(), images, json.description, "https://nijie.info/view.php?id=$id", id, tags, like, bookmark, reply, view) } private suspend fun viewCount(id: String): Int { @@ -127,7 +127,7 @@ object NijieClient { } return runCatching { - httpClient.submitForm(parameters) { + StellaHttpClient.submitForm(parameters) { url("https://nijie.info/php/ajax/add_view_count.php") header(HttpHeaders.Referrer, "https://nijie.info/view.php?id=$id") header("X-Requested-With", "XMLHttpRequest") @@ -137,7 +137,7 @@ object NijieClient { } suspend fun download(url: String, file: File) { - val response = httpClient.get(url) { + val response = StellaHttpClient.get(url) { setHeaders() header(HttpHeaders.Referrer, "https://nijie.info/") } diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieSourceProvider.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieSourceProvider.kt index 03439533..a61b4553 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieSourceProvider.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieSourceProvider.kt @@ -1,59 +1,57 @@ package blue.starry.stella.worker.platform -import blue.starry.stella.Config +import blue.starry.stella.Env import blue.starry.stella.logger import blue.starry.stella.mediaDirectory import blue.starry.stella.worker.MediaRegister +import blue.starry.stella.worker.StellaNijieClient import io.ktor.client.features.* -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import kotlin.time.minutes object NijieSourceProvider { - fun start(email: String, password: String) { - GlobalScope.launch { - while (true) { + suspend fun start(): Unit = coroutineScope { + val client = StellaNijieClient ?: return@coroutineScope + + launch { + while (isActive) { try { - fetchBookmark(email, password) + fetchBookmark(client) + } catch (e: CancellationException) { + break } catch (e: ResponseException) { // セッション切れ - if (NijieClient.isLoggedIn) { - login(email, password) + if (client.isLoggedIn) { + client.login() } } catch (e: Throwable) { logger.error(e) { "NijieSource で例外が発生しました。" } } - delay(Config.CheckIntervalMins.minutes) + delay(Env.CHECK_INTERVAL_MINS.minutes) } } } - private suspend fun fetchBookmark(email: String, password: String) { - if (!NijieClient.isLoggedIn) { - login(email, password) + private suspend fun fetchBookmark(client: NijieClient) { + if (!client.isLoggedIn) { + client.login() } - for (bookmark in NijieClient.bookmarks().reversed()) { - val picture = NijieClient.picture(bookmark.id) - register(picture, "User", false) + for (bookmark in client.bookmarks().reversed()) { + val picture = client.picture(bookmark.id) + register(client, picture, "User", false) - NijieClient.deleteBookmark(bookmark.id) + client.deleteBookmark(bookmark.id) } } - private suspend fun login(email: String, password: String) { - NijieClient.login(email, password) - logger.info { "Nijie にログインしました。" } - } - - suspend fun fetch(url: String, user: String?, auto: Boolean) { - val picture = NijieClient.picture(url.split("=").last()) - register(picture, user, auto) + suspend fun fetch(client: NijieClient, url: String, user: String?, auto: Boolean) { + val picture = client.picture(url.split("=").last()) + register(client, picture, user, auto) } - private suspend fun register(picture: NijieModel.Picture, user: String?, auto: Boolean): MediaRegister.Entry { + private suspend fun register(client: NijieClient, picture: NijieModel.Picture, user: String?, auto: Boolean): MediaRegister.Entry { return MediaRegister.Entry( title = picture.title, description = picture.description, @@ -71,7 +69,7 @@ object NijieSourceProvider { val file = mediaDirectory.resolve("nijie_${picture.id}_$index.$ext").toFile() if (!file.exists()) { - NijieClient.download(url, file) + client.download(url, file) } MediaRegister.Entry.Picture(index, "nijie_${picture.id}_$index.$ext", url, ext) diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivClient.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivClient.kt index 8097a97a..a9ada20b 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivClient.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivClient.kt @@ -1,34 +1,30 @@ package blue.starry.stella.worker.platform import blue.starry.jsonkt.parseObject -import blue.starry.stella.Config import blue.starry.stella.logger -import io.ktor.client.* -import io.ktor.client.engine.cio.* -import io.ktor.client.features.cookies.* +import blue.starry.stella.worker.StellaHttpClient import io.ktor.client.request.* import io.ktor.client.request.forms.* import io.ktor.http.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import org.apache.commons.lang3.time.FastDateFormat import java.io.File import java.security.MessageDigest +import java.time.Instant +import java.time.format.DateTimeFormatter import java.util.* -object PixivClient { +class PixivClient(private val email: String, private val password: String) { private val lock = Mutex() - private val httpClient = HttpClient(CIO) { - install(HttpCookies) - } private var token: PixivModel.Token? = null private val isLoggedIn: Boolean get() = token != null - private val dateFormat = FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ssZ", Locale.ENGLISH) + private val dateFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ", Locale.ENGLISH) private fun HttpRequestBuilder.setHeaders(credentials: Boolean) { - val time = dateFormat.format(Calendar.getInstance()) + DateTimeFormatter.ISO_DATE_TIME + val time = dateFormat.format(Instant.now()) userAgent("PixivAndroidApp/5.0.64 (Android 6.0)") header("X-Client-Time", time) @@ -49,9 +45,6 @@ object PixivClient { return } - val email = Config.PixivEmail!! - val password = Config.PixivPassword!! - val parameters = Parameters.build { append("grant_type", "password") append("client_id", "MOBrBDS8blbauoSck0ZfDbtuzpyT") @@ -62,7 +55,7 @@ object PixivClient { } token = lock.withLock(token) { - httpClient.submitForm(parameters) { + StellaHttpClient.submitForm(parameters) { url("https://oauth.secure.pixiv.net/auth/token") setHeaders(false) @@ -83,8 +76,7 @@ object PixivClient { suspend fun getBookmarks(private: Boolean): PixivModel.Bookmark { login() - return httpClient.get("https://app-api.pixiv.net/v1/user/bookmarks/illust") { - parameter("tag", "未分類") + return StellaHttpClient.get("https://app-api.pixiv.net/v1/user/bookmarks/illust") { parameter("user_id", token?.response?.user?.id) parameter("restrict", if (private) "private" else "public") @@ -102,7 +94,7 @@ object PixivClient { append("illust_id", id.toString()) } - return httpClient.submitForm("https://app-api.pixiv.net/v2/illust/bookmark/add", parameters) { + return StellaHttpClient.submitForm("https://app-api.pixiv.net/v2/illust/bookmark/add", parameters) { setHeaders(true) } } @@ -114,13 +106,13 @@ object PixivClient { append("illust_id", id.toString()) } - return httpClient.submitForm("https://app-api.pixiv.net/v1/illust/bookmark/delete", parameters) { + return StellaHttpClient.submitForm("https://app-api.pixiv.net/v1/illust/bookmark/delete", parameters) { setHeaders(true) } } suspend fun download(url: String, file: File) { - val response = httpClient.get(url) { + val response = StellaHttpClient.get(url) { setHeaders(false) header(HttpHeaders.Referrer, "https://app-api.pixiv.net/") } diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivSourceProvider.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivSourceProvider.kt index d107550f..76129e4b 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivSourceProvider.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivSourceProvider.kt @@ -1,14 +1,14 @@ package blue.starry.stella.worker.platform -import blue.starry.stella.Config +import blue.starry.stella.Env import blue.starry.stella.logger import blue.starry.stella.mediaDirectory import blue.starry.stella.worker.MediaRegister +import blue.starry.stella.worker.StellaPixivClient import io.ktor.client.features.* -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import org.apache.commons.lang3.time.FastDateFormat +import kotlinx.coroutines.* +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter import java.util.* import java.util.concurrent.CopyOnWriteArrayList import kotlin.time.minutes @@ -16,47 +16,51 @@ import kotlin.time.minutes object PixivSourceProvider { private val requestedIds = CopyOnWriteArrayList() - fun start() { - GlobalScope.launch { - while (true) { + suspend fun start(): Unit = coroutineScope { + val client = StellaPixivClient ?: return@coroutineScope + + launch { + while (isActive) { try { - fetchBookmark(false) - fetchBookmark(true) + fetchBookmark(client, false) + fetchBookmark(client, true) + } catch (e: CancellationException) { + break } catch (e: ResponseException) { - PixivClient.logout() + client.logout() } catch (e: Throwable) { logger.error(e) { "PixivSource で例外が発生しました。" } } - delay(Config.CheckIntervalMins.minutes) + delay(Env.CHECK_INTERVAL_MINS.minutes) } } } - suspend fun enqueue(url: String) { + suspend fun enqueue(client: PixivClient, url: String) { val id = url.split("=", "/").last().toInt() if (id in requestedIds) { return } - PixivClient.addBookmark(id, true) + client.addBookmark(id, true) requestedIds += id } - private suspend fun fetchBookmark(private: Boolean) { - for (illust in PixivClient.getBookmarks(private).illusts.reversed()) { + private suspend fun fetchBookmark(client: PixivClient, private: Boolean) { + for (illust in client.getBookmarks(private).illusts.reversed()) { if (illust.id in requestedIds) { - register(illust, null, true) + register(client, illust, null, true) } else { - register(illust, "User", false) + register(client, illust, "User", false) } - PixivClient.deleteBookmark(illust.id) + client.deleteBookmark(illust.id) } } - private val dateFormat = FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.ENGLISH) - private suspend fun register(illust: PixivModel.Illust, user: String?, auto: Boolean) { + private val dateFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.ENGLISH) + private suspend fun register(client: PixivClient, illust: PixivModel.Illust, user: String?, auto: Boolean) { val tags = illust.tags.map { it.name } val entry = MediaRegister.Entry( @@ -83,7 +87,7 @@ object PixivSourceProvider { else -> 1 } }, - created = dateFormat.parse(illust.createDate).time, + created = ZonedDateTime.parse(illust.createDate, dateFormat).toInstant().toEpochMilli(), media = (illust.metaSinglePage.originalImageUrl?.let { listOf(it) @@ -94,7 +98,7 @@ object PixivSourceProvider { val file = mediaDirectory.resolve("pixiv_${illust.id}_$index.$ext").toFile() if (!file.exists()) { - PixivClient.download(url, file) + client.download(url, file) } MediaRegister.Entry.Picture(index, "pixiv_${illust.id}_$index.$ext", url, ext) diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/TwitterSourceProvider.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/TwitterSourceProvider.kt index ad89419f..510daa7c 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/TwitterSourceProvider.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/TwitterSourceProvider.kt @@ -1,9 +1,6 @@ package blue.starry.stella.worker.platform -import blue.starry.penicillin.PenicillinClient -import blue.starry.penicillin.core.session.config.account -import blue.starry.penicillin.core.session.config.application -import blue.starry.penicillin.core.session.config.token +import blue.starry.penicillin.core.session.ApiClient import blue.starry.penicillin.endpoints.common.TweetMode import blue.starry.penicillin.endpoints.favorites import blue.starry.penicillin.endpoints.favorites.create @@ -21,49 +18,37 @@ import blue.starry.penicillin.extensions.execute import blue.starry.penicillin.extensions.idObj import blue.starry.penicillin.extensions.models.text import blue.starry.penicillin.models.Status -import blue.starry.stella.Config -import blue.starry.stella.logger -import blue.starry.stella.mediaDirectory +import blue.starry.stella.* import blue.starry.stella.worker.MediaRegister +import blue.starry.stella.worker.StellaHttpClient +import blue.starry.stella.worker.StellaTwitterClient import io.ktor.client.request.* -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import kotlin.time.minutes object TwitterSourceProvider { - private val client by lazy { - PenicillinClient { - account { - application( - Config.TwitterConsumerKey!!, - Config.TwitterConsumerSecret!! - ) - token( - Config.TwitterAccessToken!!, - Config.TwitterAccessTokenSecret!! - ) - } - } - } private val tweetUrlPattern = "^(?:http(?:s)?://)?(?:m|mobile)?twitter\\.com/(?:\\w|_)+?/status/(\\d+)".toRegex() - fun start() { - GlobalScope.launch { - while (true) { + suspend fun start(): Unit = coroutineScope { + val client = StellaTwitterClient ?: return@coroutineScope + + launch { + while (isActive) { try { - fetchTimeline() - fetchFavorites() + fetchTimeline(client) + fetchFavorites(client) + } catch (e: CancellationException) { + break } catch (e: Throwable) { logger.error(e) { "TwitterSource で例外が発生しました。" } } - delay(Config.CheckIntervalMins.minutes) + delay(Env.CHECK_INTERVAL_MINS.minutes) } } } - private suspend fun fetchTimeline() { + private suspend fun fetchTimeline(client: ApiClient) { val timeline = client.timeline.userTimeline().execute() for (status in timeline) { if (status.retweetedStatus != null) { @@ -85,7 +70,7 @@ object TwitterSourceProvider { } } - private suspend fun fetchFavorites() { + private suspend fun fetchFavorites(client: ApiClient) { val favorites = client.favorites.list(options = arrayOf("tweet_mode" to TweetMode.Extended)).execute() for (status in favorites) { register(status, "User", false) @@ -97,10 +82,13 @@ object TwitterSourceProvider { } } - suspend fun fetch(url: String, user: String?, auto: Boolean) { - val status = client.statuses.show(url.split("/").last().split("?").first().toLong(), options = arrayOf("tweet_mode" to TweetMode.Extended)).execute().result + suspend fun fetch(client: ApiClient, url: String, user: String?, auto: Boolean) { + val status = client.statuses.show( + id = url.split("/").last().split("?").first().toLong(), + tweetMode = TweetMode.Extended + ).execute() - register(status, user, auto) + register(status.result, user, auto) } private val tcoRegex = "https://t\\.co/[a-zA-Z0-9]+".toRegex() @@ -131,7 +119,7 @@ object TwitterSourceProvider { val file = mediaDirectory.resolve("twitter_${status.idStr}_$i.$ext").toFile() if (!file.exists()) { file.outputStream().use { - val response = client.session.httpClient.get(url) + val response = StellaHttpClient.get(url) it.write(response) } } From d8df8a9f3a448a3699e146e7d82785ab2d58ff10 Mon Sep 17 00:00:00 2001 From: Nep Date: Sat, 27 Feb 2021 21:46:26 +0900 Subject: [PATCH 03/19] Fix COPY --- Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 929087d3..9e36bfcf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,6 @@ FROM gradle:jdk8 AS build WORKDIR /app COPY --from=cache /app/gradle /home/gradle/.gradle COPY *.gradle.kts gradle.properties /app/ -COPY src/commonMain/ /app/src/commonMain/ COPY src/jvmMain/ /app/src/jvmMain/ # Stop printing Welcome RUN gradle -version > /dev/null \ From c5d1827f886b040de5e8752dd9a103b30311eda4 Mon Sep 17 00:00:00 2001 From: Nep Date: Sat, 27 Feb 2021 22:01:48 +0900 Subject: [PATCH 04/19] Downgrade KMongo due to NoClassDefFoundError --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index f51ef964..f3ac1fd1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,7 +11,7 @@ plugins { object Versions { const val Ktor = "1.5.2" const val Penicillin = "6.0.5" - const val KMongo = "4.2.4" + const val KMongo = "4.2.3" const val Jsoup = "1.13.1" const val JUnit = "5.7.0" From 379e23731cc93653cf03820865813ab6393fe49d Mon Sep 17 00:00:00 2001 From: Nep Date: Sat, 27 Feb 2021 22:39:10 +0900 Subject: [PATCH 05/19] Fix serialization error --- build.gradle.kts | 6 +++-- docs/static/script.js | 6 +---- src/jvmMain/kotlin/blue/starry/stella/App.kt | 25 ++++++++++--------- .../blue/starry/stella/endpoints/Common.kt | 22 +++------------- .../blue/starry/stella/endpoints/GetQuery.kt | 3 ++- .../starry/stella/endpoints/GetQueryTags.kt | 5 ++-- .../starry/stella/endpoints/GetSummary.kt | 3 ++- .../stella/endpoints/PatchSensitiveLevel.kt | 11 ++++---- .../starry/stella/endpoints/PutEditTag.kt | 19 ++++++-------- .../starry/stella/endpoints/PutRefresh.kt | 9 +++---- .../starry/stella/models/ApiErrorModel.kt | 9 +++++++ .../starry/stella/models/ApiResponseModel.kt | 17 ------------- .../blue/starry/stella/models/PicModel.kt | 3 ++- .../starry/stella/worker/MediaRegister.kt | 2 +- 14 files changed, 58 insertions(+), 82 deletions(-) create mode 100644 src/jvmMain/kotlin/blue/starry/stella/models/ApiErrorModel.kt delete mode 100644 src/jvmMain/kotlin/blue/starry/stella/models/ApiResponseModel.kt diff --git a/build.gradle.kts b/build.gradle.kts index f3ac1fd1..e75f9f8f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,7 +27,8 @@ object Libraries { const val KtorSerialization = "io.ktor:ktor-serialization:${Versions.Ktor}" const val KtorClientCIO = "io.ktor:ktor-client-cio:${Versions.Ktor}" - const val KMongoCoroutine = "org.litote.kmongo:kmongo-coroutine:${Versions.KMongo}" + const val KMongoCoroutineSerialization = "org.litote.kmongo:kmongo-coroutine-serialization:${Versions.KMongo}" + const val KMongoIdSerialization = "org.litote.kmongo:kmongo-id-serialization:${Versions.KMongo}" const val Penicillin = "blue.starry:penicillin:${Versions.Penicillin}" const val Jsoup = "org.jsoup:jsoup:${Versions.Jsoup}" @@ -90,7 +91,8 @@ kotlin { implementation(Libraries.KtorSerialization) implementation(Libraries.KtorClientCIO) - implementation(Libraries.KMongoCoroutine) + implementation(Libraries.KMongoCoroutineSerialization) + implementation(Libraries.KMongoIdSerialization) implementation(Libraries.Jsoup) implementation(Libraries.LogbackCore) diff --git a/docs/static/script.js b/docs/static/script.js index 337937a9..7051671e 100644 --- a/docs/static/script.js +++ b/docs/static/script.js @@ -1219,11 +1219,7 @@ xhr.onload = () => { try { const json = JSON.parse(xhr.responseText); - if (json.success && xhr.status === 200) { - resolve(json.result); - } else { - reject(new Error(json.error)); - } + resolve(json); } catch (e) { reject(e); } diff --git a/src/jvmMain/kotlin/blue/starry/stella/App.kt b/src/jvmMain/kotlin/blue/starry/stella/App.kt index f3946e4a..00439413 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/App.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/App.kt @@ -14,30 +14,30 @@ import io.ktor.routing.* import io.ktor.serialization.* import io.ktor.server.cio.* import io.ktor.server.engine.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import mu.KotlinLogging +import org.litote.kmongo.id.serialization.IdKotlinXSerializationModule import java.nio.file.Files import java.nio.file.Paths internal val logger = KotlinLogging.logger("Stella") internal val mediaDirectory = Paths.get("media") -suspend fun main() { +fun main() { if (!Files.exists(mediaDirectory)) { - withContext(Dispatchers.IO) { - @Suppress("BlockingMethodInNonBlockingContext") - Files.createDirectory(mediaDirectory) - } + Files.createDirectory(mediaDirectory) } - RefreshWorker.start() - MissingMediaRefetchWorker.start() + GlobalScope.launch { + RefreshWorker.start() + MissingMediaRefetchWorker.start() - TwitterSourceProvider.start() - PixivSourceProvider.start() - NijieSourceProvider.start() + TwitterSourceProvider.start() + PixivSourceProvider.start() + NijieSourceProvider.start() + } embeddedServer(CIO, host = Env.HTTP_HOST, port = Env.HTTP_PORT, module = Application::module).start(wait = true) } @@ -48,6 +48,7 @@ fun Application.module() { install(ContentNegotiation) { json(Json { encodeDefaults = true + serializersModule = IdKotlinXSerializationModule }) } diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/Common.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/Common.kt index df6a47f1..474d8b70 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/Common.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/Common.kt @@ -1,32 +1,18 @@ package blue.starry.stella.endpoints -import blue.starry.stella.models.ApiResponseModel +import blue.starry.stella.models.ApiErrorModel import io.ktor.application.* import io.ktor.http.* import io.ktor.response.* -suspend fun ApplicationCall.respondApiResponse(result: Any) { - respond( - ApiResponseModel( - success = true, - result = result, - error = null - ) - ) -} - suspend fun ApplicationCall.respondApiError( code: HttpStatusCode = HttpStatusCode.InternalServerError, block: () -> String ) { respond( - ApiResponseModel( - success = true, - result = null, - error = ApiResponseModel.Error( - code = code.value, - message = block() - ) + ApiErrorModel( + code = code.value, + message = block() ) ) } diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQuery.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQuery.kt index ae17ea85..d8581364 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQuery.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQuery.kt @@ -5,6 +5,7 @@ import blue.starry.stella.worker.StellaMongoDBPicCollection import io.ktor.application.* import io.ktor.locations.* import io.ktor.request.* +import io.ktor.response.* import io.ktor.routing.* import kotlinx.coroutines.flow.flattenConcat import kotlinx.coroutines.flow.flowOf @@ -70,7 +71,7 @@ fun Route.getQuery() { GetQueryPipelines.limit(param.sort, param.page, param.count) ).flattenConcat().toList() - call.respondApiResponse( + call.respond( StellaMongoDBPicCollection.aggregate(pipeline).toList() ) } diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTags.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTags.kt index 45050bca..c80c80f2 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTags.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTags.kt @@ -5,6 +5,7 @@ import blue.starry.stella.models.PicTagsModel import blue.starry.stella.worker.StellaMongoDBPicCollection import io.ktor.application.* import io.ktor.locations.* +import io.ktor.response.* import io.ktor.routing.* import org.litote.kmongo.* import org.litote.kmongo.coroutine.aggregate @@ -21,7 +22,7 @@ data class GetQueryTags( fun Route.getQueryTags() { get { param -> val existingTags = param.id?.let { id -> - StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId())?.tags + StellaMongoDBPicCollection.findOne(PicModel::id eq id.toId())?.tags }.orEmpty().map { it.value } @@ -62,7 +63,7 @@ fun Route.getQueryTags() { .take(param.count) .toList() - call.respondApiResponse( + call.respond( PicTagsModel( tags = tags ) diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetSummary.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetSummary.kt index e7871059..923c4e51 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetSummary.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetSummary.kt @@ -4,6 +4,7 @@ import blue.starry.stella.mediaDirectory import blue.starry.stella.models.PicSummaryModel import blue.starry.stella.worker.StellaMongoDBPicCollection import io.ktor.application.* +import io.ktor.response.* import io.ktor.routing.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -11,7 +12,7 @@ import java.nio.file.Files fun Route.getSummary() { get("/summary") { - call.respondApiResponse( + call.respond( PicSummaryModel( entries = StellaMongoDBPicCollection.countDocuments(), media = withContext(Dispatchers.IO) { diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchSensitiveLevel.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchSensitiveLevel.kt index a53735ec..fab70192 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchSensitiveLevel.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchSensitiveLevel.kt @@ -10,6 +10,7 @@ import io.ktor.http.* import io.ktor.locations.* import io.ktor.locations.patch import io.ktor.request.* +import io.ktor.response.* import io.ktor.routing.Route import org.litote.kmongo.combine import org.litote.kmongo.eq @@ -29,24 +30,22 @@ fun Route.patchSensitiveLevel() { } } - if (StellaMongoDBPicCollection.countDocuments(PicModel::_id eq param.id.toId()) == 0L) { + if (StellaMongoDBPicCollection.countDocuments(PicModel::id eq param.id.toId()) == 0L) { return@patch call.respondApiError(HttpStatusCode.NotFound) { "Specified entry is not found." } } StellaMongoDBPicCollection.updateOne( - PicModel::_id eq param.id.toId(), + PicModel::id eq param.id.toId(), combine( setValue(PicModel::sensitive_level, param.sensitive_level), Updates.set("timestamp.manual_updated", Instant.now().toEpochMilli()) ) ) - val entry = StellaMongoDBPicCollection.findOne(PicModel::_id eq param.id.toId())!! - call.respondApiResponse { - entry - } + val entry = StellaMongoDBPicCollection.findOne(PicModel::id eq param.id.toId())!! + call.respond(entry) logger.info { "${entry.url} のエントリが更新されました。sensitive_level が ${param.sensitive_level} に変更されました。(${call.request.origin.remoteHost})" diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutEditTag.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutEditTag.kt index 1bec5238..d0f86cd0 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutEditTag.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutEditTag.kt @@ -10,6 +10,7 @@ import io.ktor.features.* import io.ktor.http.* import io.ktor.locations.* import io.ktor.request.* +import io.ktor.response.* import io.ktor.routing.Route import org.bson.types.ObjectId import org.litote.kmongo.eq @@ -30,7 +31,7 @@ fun Route.putEditTag() { return@put } - val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId()) + val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::id eq id.toId()) if (oldEntry == null) { call.respondApiError(HttpStatusCode.NotFound) { "Specified entry is not found." @@ -55,12 +56,10 @@ fun Route.putEditTag() { )), Updates.set("timestamp.manual_updated", Calendar.getInstance().timeInMillis) ) - StellaMongoDBPicCollection.updateOne(PicModel::_id eq id.toId(), updates) + StellaMongoDBPicCollection.updateOne(PicModel::id eq id.toId(), updates) val entry = StellaMongoDBPicCollection.findOne(Filters.eq("_id", ObjectId(id)))!! - call.respondApiResponse { - entry - } + call.respond(entry) logger.info { "${entry.url} のエントリが更新されました。「$tag」が追加されました。(${call.request.origin.remoteHost})" @@ -79,7 +78,7 @@ fun Route.deleteEditTag() { return@delete } - val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId()) + val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::id eq id.toId()) if (oldEntry == null) { call.respondApiError(HttpStatusCode.NotFound) { "Specified entry is not found." @@ -116,12 +115,10 @@ fun Route.deleteEditTag() { Updates.set("tags", tags), Updates.set("timestamp.manual_updated", Calendar.getInstance().timeInMillis) ) - StellaMongoDBPicCollection.updateOne(PicModel::_id eq id.toId(), updates) + StellaMongoDBPicCollection.updateOne(PicModel::id eq id.toId(), updates) - val entry = StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId())!! - call.respondApiResponse { - entry - } + val entry = StellaMongoDBPicCollection.findOne(PicModel::id eq id.toId())!! + call.respond(entry) logger.info { "${entry.url} のエントリが更新されました。「$tag」が削除されました。(${call.request.origin.remoteHost})" diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutRefresh.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutRefresh.kt index a94162a9..3afa970b 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutRefresh.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutRefresh.kt @@ -7,6 +7,7 @@ import io.ktor.application.* import io.ktor.http.* import io.ktor.locations.* import io.ktor.locations.put +import io.ktor.response.* import io.ktor.routing.Route import org.litote.kmongo.eq import org.litote.kmongo.toId @@ -16,7 +17,7 @@ data class PutRefresh(val id: String) fun Route.putRefresh() { put { (id) -> - val entry = StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId()) + val entry = StellaMongoDBPicCollection.findOne(PicModel::id eq id.toId()) ?: return@put call.respondApiError(HttpStatusCode.NotFound) { "Specified entry is not found." } @@ -26,11 +27,9 @@ fun Route.putRefresh() { "Unknown error occurred." } } else { - val newEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId())!! + val newEntry = StellaMongoDBPicCollection.findOne(PicModel::id eq id.toId())!! - call.respondApiResponse { - newEntry - } + call.respond(newEntry) } } } diff --git a/src/jvmMain/kotlin/blue/starry/stella/models/ApiErrorModel.kt b/src/jvmMain/kotlin/blue/starry/stella/models/ApiErrorModel.kt new file mode 100644 index 00000000..b69c5cfa --- /dev/null +++ b/src/jvmMain/kotlin/blue/starry/stella/models/ApiErrorModel.kt @@ -0,0 +1,9 @@ +package blue.starry.stella.models + +import kotlinx.serialization.Serializable + +@Serializable +data class ApiErrorModel( + val code: Int, + val message: String +) diff --git a/src/jvmMain/kotlin/blue/starry/stella/models/ApiResponseModel.kt b/src/jvmMain/kotlin/blue/starry/stella/models/ApiResponseModel.kt deleted file mode 100644 index d71846de..00000000 --- a/src/jvmMain/kotlin/blue/starry/stella/models/ApiResponseModel.kt +++ /dev/null @@ -1,17 +0,0 @@ -package blue.starry.stella.models - -import kotlinx.serialization.Contextual -import kotlinx.serialization.Serializable - -@Serializable -data class ApiResponseModel( - val success: Boolean, - @Contextual val result: Any?, - val error: Error? -) { - @Serializable - data class Error( - val code: Int, - val message: String - ) -} diff --git a/src/jvmMain/kotlin/blue/starry/stella/models/PicModel.kt b/src/jvmMain/kotlin/blue/starry/stella/models/PicModel.kt index e0aed06a..19d654ba 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/models/PicModel.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/models/PicModel.kt @@ -1,12 +1,13 @@ package blue.starry.stella.models import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.litote.kmongo.Id @Serializable data class PicModel( - @Contextual val _id: Id, + @SerialName("_id") @Contextual val id: Id, val title: String, val description: String, val url: String, diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/MediaRegister.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/MediaRegister.kt index c9ae520e..b265ebe0 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/MediaRegister.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/MediaRegister.kt @@ -32,7 +32,7 @@ object MediaRegister { suspend fun register(entry: Entry, auto: Boolean) { val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::url eq entry.url) val newEntry = PicModel( - _id = oldEntry?._id ?: newId(), + id = oldEntry?.id ?: newId(), title = entry.title.normalizeTitle(), description = entry.description.normalizeDescription(), url = entry.url, From d318797b90f49781f1f50a955e619bb802670d9c Mon Sep 17 00:00:00 2001 From: Nep Date: Sat, 27 Feb 2021 22:42:44 +0900 Subject: [PATCH 06/19] Revert _id renaming --- docs/static/script.js | 18 +++++++++--------- .../starry/stella/endpoints/GetQueryTags.kt | 2 +- .../stella/endpoints/PatchSensitiveLevel.kt | 6 +++--- .../blue/starry/stella/endpoints/PutEditTag.kt | 10 +++++----- .../blue/starry/stella/endpoints/PutRefresh.kt | 4 ++-- .../blue/starry/stella/models/PicModel.kt | 3 +-- .../blue/starry/stella/worker/MediaRegister.kt | 2 +- 7 files changed, 22 insertions(+), 23 deletions(-) diff --git a/docs/static/script.js b/docs/static/script.js index 7051671e..9d7198ce 100644 --- a/docs/static/script.js +++ b/docs/static/script.js @@ -481,7 +481,7 @@ createMediaItem() { const a = document.createElement("a"); a.classList.add("direction-reveal__card", "media-item"); - a.setAttribute("data-id", this.pic.id); + a.setAttribute("data-id", this.pic._id); return a; } @@ -1153,7 +1153,7 @@ this.pic.tags.push({user: null, locked: false, value: tagValue}); this.tagCurrentResult.appendChild(this.createDeleteTagEach({user: null, locked: false, value: tagValue})); - for (const element of Array.from(Container.element.querySelectorAll(`a.media-item[data-id="${this.pic.id}"] .overlay-tags`))) { + for (const element of Array.from(Container.element.querySelectorAll(`a.media-item[data-id="${this.pic._id}"] .overlay-tags`))) { element.insertBefore(PicOverlayBuilder.createTagEach({user: null, locked: false, value: tagValue}), element.lastChild); } @@ -1164,7 +1164,7 @@ this.pic.tags = this.pic.tags.filter(e => e.value !== tagValue); this.tagRelationalResult.appendChild(this.createAddTagEach(tagValue)); - for (const element of Array.from(Container.element.querySelector(`a.media-item[data-id="${this.pic.id}"] a[data-tag=\"${tagValue}\"]`))) { + for (const element of Array.from(Container.element.querySelector(`a.media-item[data-id="${this.pic._id}"] a[data-tag=\"${tagValue}\"]`))) { element.remove(); } @@ -1248,12 +1248,12 @@ }).map(value => `${encodeURIComponent(value[0])}=${encodeURIComponent(value[1])}`).join("&"); }, summary: () => API.asyncRequest("GET", "/summary", null, null), - refreshEntry: pic => API.asyncRequest("PUT", `/refresh/${pic.id}`, null, null), - addTag: (pic, tag) => API.asyncRequest("PUT", `/edit/${pic.id}/tag`, null, {tag: tag}), - deleteTag: (pic, tag) => API.asyncRequest("DELETE", `/edit/${pic.id}/tag`, null, {tag: tag}), - updateSensitiveLevel: (pic, level) => API.asyncRequest("PATCH", `/edit/${pic.id}/sensitive_level`, null, {sensitive_level: level}), - relationalTags: pic => API.asyncRequest("GET", "/query/tags", {id: pic.id, sensitive_level: pic.sensitive_level, count: 20}, null), - searchTags: (pic, name) => API.asyncRequest("GET", "/query/tags", {id: pic.id, name: name, sensitive_level: pic.sensitive_level, count: 30}, null) + refreshEntry: pic => API.asyncRequest("PUT", `/refresh/${pic._id}`, null, null), + addTag: (pic, tag) => API.asyncRequest("PUT", `/edit/${pic._id}/tag`, null, {tag: tag}), + deleteTag: (pic, tag) => API.asyncRequest("DELETE", `/edit/${pic._id}/tag`, null, {tag: tag}), + updateSensitiveLevel: (pic, level) => API.asyncRequest("PATCH", `/edit/${pic._id}/sensitive_level`, null, {sensitive_level: level}), + relationalTags: pic => API.asyncRequest("GET", "/query/tags", {id: pic._id, sensitive_level: pic.sensitive_level, count: 20}, null), + searchTags: (pic, name) => API.asyncRequest("GET", "/query/tags", {id: pic._id, name: name, sensitive_level: pic.sensitive_level, count: 30}, null) }; if (App.isMobile) { diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTags.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTags.kt index c80c80f2..5d20e0b7 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTags.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTags.kt @@ -22,7 +22,7 @@ data class GetQueryTags( fun Route.getQueryTags() { get { param -> val existingTags = param.id?.let { id -> - StellaMongoDBPicCollection.findOne(PicModel::id eq id.toId())?.tags + StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId())?.tags }.orEmpty().map { it.value } diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchSensitiveLevel.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchSensitiveLevel.kt index fab70192..becae4e2 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchSensitiveLevel.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchSensitiveLevel.kt @@ -30,21 +30,21 @@ fun Route.patchSensitiveLevel() { } } - if (StellaMongoDBPicCollection.countDocuments(PicModel::id eq param.id.toId()) == 0L) { + if (StellaMongoDBPicCollection.countDocuments(PicModel::_id eq param.id.toId()) == 0L) { return@patch call.respondApiError(HttpStatusCode.NotFound) { "Specified entry is not found." } } StellaMongoDBPicCollection.updateOne( - PicModel::id eq param.id.toId(), + PicModel::_id eq param.id.toId(), combine( setValue(PicModel::sensitive_level, param.sensitive_level), Updates.set("timestamp.manual_updated", Instant.now().toEpochMilli()) ) ) - val entry = StellaMongoDBPicCollection.findOne(PicModel::id eq param.id.toId())!! + val entry = StellaMongoDBPicCollection.findOne(PicModel::_id eq param.id.toId())!! call.respond(entry) logger.info { diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutEditTag.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutEditTag.kt index d0f86cd0..c91d97ff 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutEditTag.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutEditTag.kt @@ -31,7 +31,7 @@ fun Route.putEditTag() { return@put } - val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::id eq id.toId()) + val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId()) if (oldEntry == null) { call.respondApiError(HttpStatusCode.NotFound) { "Specified entry is not found." @@ -56,7 +56,7 @@ fun Route.putEditTag() { )), Updates.set("timestamp.manual_updated", Calendar.getInstance().timeInMillis) ) - StellaMongoDBPicCollection.updateOne(PicModel::id eq id.toId(), updates) + StellaMongoDBPicCollection.updateOne(PicModel::_id eq id.toId(), updates) val entry = StellaMongoDBPicCollection.findOne(Filters.eq("_id", ObjectId(id)))!! call.respond(entry) @@ -78,7 +78,7 @@ fun Route.deleteEditTag() { return@delete } - val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::id eq id.toId()) + val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId()) if (oldEntry == null) { call.respondApiError(HttpStatusCode.NotFound) { "Specified entry is not found." @@ -115,9 +115,9 @@ fun Route.deleteEditTag() { Updates.set("tags", tags), Updates.set("timestamp.manual_updated", Calendar.getInstance().timeInMillis) ) - StellaMongoDBPicCollection.updateOne(PicModel::id eq id.toId(), updates) + StellaMongoDBPicCollection.updateOne(PicModel::_id eq id.toId(), updates) - val entry = StellaMongoDBPicCollection.findOne(PicModel::id eq id.toId())!! + val entry = StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId())!! call.respond(entry) logger.info { diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutRefresh.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutRefresh.kt index 3afa970b..43e3e93d 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutRefresh.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutRefresh.kt @@ -17,7 +17,7 @@ data class PutRefresh(val id: String) fun Route.putRefresh() { put { (id) -> - val entry = StellaMongoDBPicCollection.findOne(PicModel::id eq id.toId()) + val entry = StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId()) ?: return@put call.respondApiError(HttpStatusCode.NotFound) { "Specified entry is not found." } @@ -27,7 +27,7 @@ fun Route.putRefresh() { "Unknown error occurred." } } else { - val newEntry = StellaMongoDBPicCollection.findOne(PicModel::id eq id.toId())!! + val newEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId())!! call.respond(newEntry) } diff --git a/src/jvmMain/kotlin/blue/starry/stella/models/PicModel.kt b/src/jvmMain/kotlin/blue/starry/stella/models/PicModel.kt index 19d654ba..e0aed06a 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/models/PicModel.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/models/PicModel.kt @@ -1,13 +1,12 @@ package blue.starry.stella.models import kotlinx.serialization.Contextual -import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.litote.kmongo.Id @Serializable data class PicModel( - @SerialName("_id") @Contextual val id: Id, + @Contextual val _id: Id, val title: String, val description: String, val url: String, diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/MediaRegister.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/MediaRegister.kt index b265ebe0..c9ae520e 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/MediaRegister.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/MediaRegister.kt @@ -32,7 +32,7 @@ object MediaRegister { suspend fun register(entry: Entry, auto: Boolean) { val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::url eq entry.url) val newEntry = PicModel( - id = oldEntry?.id ?: newId(), + _id = oldEntry?._id ?: newId(), title = entry.title.normalizeTitle(), description = entry.description.normalizeDescription(), url = entry.url, From a48ee40d9c0c3fd528f74744e81f6922469645e9 Mon Sep 17 00:00:00 2001 From: Nep Date: Sat, 27 Feb 2021 23:09:31 +0900 Subject: [PATCH 07/19] Replace type-unsafe mongo java binding --- .../blue/starry/stella/endpoints/EditTag.kt | 85 ++++++++++++ .../starry/stella/endpoints/GetQueryTags.kt | 35 +++-- .../stella/endpoints/GetQueryTagsFilters.kt | 38 ++++++ .../stella/endpoints/PatchSensitiveLevel.kt | 10 +- .../starry/stella/endpoints/PutEditTag.kt | 127 ------------------ .../worker/MissingMediaRefetchWorker.kt | 7 +- 6 files changed, 145 insertions(+), 157 deletions(-) create mode 100644 src/jvmMain/kotlin/blue/starry/stella/endpoints/EditTag.kt create mode 100644 src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTagsFilters.kt delete mode 100644 src/jvmMain/kotlin/blue/starry/stella/endpoints/PutEditTag.kt diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/EditTag.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/EditTag.kt new file mode 100644 index 00000000..71ccafb3 --- /dev/null +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/EditTag.kt @@ -0,0 +1,85 @@ +package blue.starry.stella.endpoints + +import blue.starry.stella.logger +import blue.starry.stella.models.PicModel +import blue.starry.stella.worker.StellaMongoDBPicCollection +import io.ktor.application.* +import io.ktor.features.* +import io.ktor.http.* +import io.ktor.locations.* +import io.ktor.request.* +import io.ktor.response.* +import io.ktor.routing.Route +import org.litote.kmongo.* +import java.time.Instant +import java.util.* + +@Location("/edit/{id}/tag") +data class EditTag(val id: String, val tag: String) + +fun Route.putEditTag() { + put { param -> + val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq param.id.toId()) + ?: return@put call.respondApiError(HttpStatusCode.NotFound) { + "Specified entry is not found." + } + + if (param.tag in oldEntry.tags.map { it.value }) { + return@put call.respondApiError(HttpStatusCode.BadRequest) { + "Input tag already exists in database." + } + } + + val updates = combine( + addToSet(PicModel::tags, PicModel.Tag( + value = param.tag, + user = call.request.origin.remoteHost, + locked = false + )), + setValue(PicModel::timestamp / PicModel.Timestamp::manual_updated, Instant.now().toEpochMilli()) + ) + StellaMongoDBPicCollection.updateOne(PicModel::_id eq param.id.toId(), updates) + + val entry = StellaMongoDBPicCollection.findOne(PicModel::_id eq param.id.toId())!! + call.respond(entry) + + logger.info { + "${entry.url} のエントリが更新されました。「${param.tag}」が追加されました。(${call.request.origin.remoteHost})" + } + } +} + +fun Route.deleteEditTag() { + delete { param -> + val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq param.id.toId()) + ?: return@delete call.respondApiError(HttpStatusCode.NotFound) { + "Specified entry is not found." + } + + if (param.tag !in oldEntry.tags.map { it.value }) { + return@delete call.respondApiError(HttpStatusCode.BadRequest) { + "Input tag is not found in database." + } + } + + if (param.tag in oldEntry.tags.filter { it.locked }.map { it.value }) { + return@delete call.respondApiError(HttpStatusCode.BadRequest) { + "Input tag is locked in database." + } + } + + val tags = oldEntry.tags.filter { it.value != param.tag } + val updates = combine( + setValue(PicModel::tags, tags), + setValue(PicModel::timestamp / PicModel.Timestamp::manual_updated, Instant.now().toEpochMilli()) + ) + StellaMongoDBPicCollection.updateOne(PicModel::_id eq param.id.toId(), updates) + + val entry = StellaMongoDBPicCollection.findOne(PicModel::_id eq param.id.toId())!! + call.respond(entry) + + logger.info { + "${entry.url} のエントリが更新されました。「${param.tag}」が削除されました。(${call.request.origin.remoteHost})" + } + } +} diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTags.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTags.kt index 5d20e0b7..4ea72eff 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTags.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTags.kt @@ -7,8 +7,14 @@ import io.ktor.application.* import io.ktor.locations.* import io.ktor.response.* import io.ktor.routing.* -import org.litote.kmongo.* +import kotlinx.coroutines.flow.flattenConcat +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import org.litote.kmongo.and import org.litote.kmongo.coroutine.aggregate +import org.litote.kmongo.eq +import org.litote.kmongo.match +import org.litote.kmongo.toId @Location("/query/tags") data class GetQueryTags( @@ -23,27 +29,16 @@ fun Route.getQueryTags() { get { param -> val existingTags = param.id?.let { id -> StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId())?.tags - }.orEmpty().map { + }?.map { it.value - } + }.orEmpty() - val filters = buildList { - this += not(PicModel::tags.size(0)) - - if (param.name != null) { - this += PicModel::tags.elemMatch(PicModel.Tag::value.regex(param.name, "i")) - } - - if (existingTags.isNotEmpty()) { - this += or(existingTags.map { tag -> - PicModel::tags.elemMatch(PicModel.Tag::value.regex(tag, "i")) - }) - } - - if (param.sensitive_level != null) { - this += PicModel::sensitive_level lte param.sensitive_level - } - } + val filters = flowOf( + GetQueryTagsFilters.ensureNotEmpty(), + GetQueryTagsFilters.name(param.name), + GetQueryTagsFilters.existingTags(existingTags), + GetQueryTagsFilters.sensitiveLevel(param.sensitive_level) + ).flattenConcat().toList() val tags = StellaMongoDBPicCollection .aggregate(match(and(filters))) diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTagsFilters.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTagsFilters.kt new file mode 100644 index 00000000..2da43677 --- /dev/null +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTagsFilters.kt @@ -0,0 +1,38 @@ +package blue.starry.stella.endpoints + +import blue.starry.stella.models.PicModel +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import org.litote.kmongo.* + +object GetQueryTagsFilters { + fun ensureNotEmpty() = flowOf( + not(PicModel::tags.size(0)) + ) + + fun name(value: String?) = flow { + if (value != null) { + emit( + PicModel::tags.elemMatch(PicModel.Tag::value.regex(value, "i")) + ) + } + } + + fun existingTags(tags: List) = flow { + if (tags.isNotEmpty()) { + emit( + or(tags.map { tag -> + PicModel::tags.elemMatch(PicModel.Tag::value.regex(tag, "i")) + }) + ) + } + } + + fun sensitiveLevel(value: Int?) = flow { + if (value != null) { + emit( + PicModel::sensitive_level lte value + ) + } + } +} diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchSensitiveLevel.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchSensitiveLevel.kt index becae4e2..7460dfd8 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchSensitiveLevel.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchSensitiveLevel.kt @@ -3,7 +3,6 @@ package blue.starry.stella.endpoints import blue.starry.stella.logger import blue.starry.stella.models.PicModel import blue.starry.stella.worker.StellaMongoDBPicCollection -import com.mongodb.client.model.Updates import io.ktor.application.* import io.ktor.features.* import io.ktor.http.* @@ -12,10 +11,7 @@ import io.ktor.locations.patch import io.ktor.request.* import io.ktor.response.* import io.ktor.routing.Route -import org.litote.kmongo.combine -import org.litote.kmongo.eq -import org.litote.kmongo.setValue -import org.litote.kmongo.toId +import org.litote.kmongo.* import java.time.Instant import java.util.* @@ -26,7 +22,7 @@ fun Route.patchSensitiveLevel() { patch { param -> if (param.sensitive_level !in 0..3) { return@patch call.respondApiError(HttpStatusCode.BadRequest) { - "Essential \"sensitive_level\" is invalid or not present." + "Essential \"sensitive_level\" is invalid." } } @@ -40,7 +36,7 @@ fun Route.patchSensitiveLevel() { PicModel::_id eq param.id.toId(), combine( setValue(PicModel::sensitive_level, param.sensitive_level), - Updates.set("timestamp.manual_updated", Instant.now().toEpochMilli()) + setValue(PicModel::timestamp / PicModel.Timestamp::manual_updated, Instant.now().toEpochMilli()) ) ) diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutEditTag.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutEditTag.kt deleted file mode 100644 index c91d97ff..00000000 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutEditTag.kt +++ /dev/null @@ -1,127 +0,0 @@ -package blue.starry.stella.endpoints - -import blue.starry.stella.logger -import blue.starry.stella.models.PicModel -import blue.starry.stella.worker.StellaMongoDBPicCollection -import com.mongodb.client.model.Filters -import com.mongodb.client.model.Updates -import io.ktor.application.* -import io.ktor.features.* -import io.ktor.http.* -import io.ktor.locations.* -import io.ktor.request.* -import io.ktor.response.* -import io.ktor.routing.Route -import org.bson.types.ObjectId -import org.litote.kmongo.eq -import org.litote.kmongo.toId -import java.util.* - -@Location("/edit/{id}/tag") -data class PutEditTag(val id: String) - -fun Route.putEditTag() { - put { (id) -> - val tag = call.receiveParameters()["tag"] - if (tag == null) { - call.respondApiError(HttpStatusCode.BadRequest) { - "Essential parameter \"tag\" is invalid or not set." - } - - return@put - } - - val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId()) - if (oldEntry == null) { - call.respondApiError(HttpStatusCode.NotFound) { - "Specified entry is not found." - } - - return@put - } - - if (tag in oldEntry.tags.map { it.value }) { - call.respondApiError(HttpStatusCode.BadRequest) { - "Input tag already exists in database." - } - - return@put - } - - val updates = Updates.combine( - Updates.addToSet("tags", mapOf( - "value" to tag, - "user" to call.request.origin.remoteHost, - "locked" to false - )), - Updates.set("timestamp.manual_updated", Calendar.getInstance().timeInMillis) - ) - StellaMongoDBPicCollection.updateOne(PicModel::_id eq id.toId(), updates) - - val entry = StellaMongoDBPicCollection.findOne(Filters.eq("_id", ObjectId(id)))!! - call.respond(entry) - - logger.info { - "${entry.url} のエントリが更新されました。「$tag」が追加されました。(${call.request.origin.remoteHost})" - } - } -} - -fun Route.deleteEditTag() { - delete { (id) -> - val tag = call.receiveParameters()["tag"] - if (tag == null) { - call.respondApiError(HttpStatusCode.BadRequest) { - "Essential parameter \"tag\" is invalid or not set." - } - - return@delete - } - - val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId()) - if (oldEntry == null) { - call.respondApiError(HttpStatusCode.NotFound) { - "Specified entry is not found." - } - - return@delete - } - - if (tag !in oldEntry.tags.map { it.value }) { - call.respondApiError(HttpStatusCode.BadRequest) { - "Input tag is not found in database." - } - - return@delete - } - - if (tag in oldEntry.tags.filter { it.locked }.map { it.value }) { - call.respondApiError(HttpStatusCode.BadRequest) { - "Input tag is locked in database." - } - - return@delete - } - - val tags = oldEntry.tags.filter { it.value != tag }.map { - mapOf( - "value" to it.value, - "user" to it.user, - "locked" to it.locked - ) - } - - val updates = Updates.combine( - Updates.set("tags", tags), - Updates.set("timestamp.manual_updated", Calendar.getInstance().timeInMillis) - ) - StellaMongoDBPicCollection.updateOne(PicModel::_id eq id.toId(), updates) - - val entry = StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId())!! - call.respond(entry) - - logger.info { - "${entry.url} のエントリが更新されました。「$tag」が削除されました。(${call.request.origin.remoteHost})" - } - } -} diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/MissingMediaRefetchWorker.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/MissingMediaRefetchWorker.kt index 55508115..de1b4bbb 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/MissingMediaRefetchWorker.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/MissingMediaRefetchWorker.kt @@ -4,8 +4,9 @@ import blue.starry.penicillin.core.exceptions.PenicillinTwitterApiException import blue.starry.penicillin.core.exceptions.TwitterApiError import blue.starry.stella.logger import blue.starry.stella.mediaDirectory -import com.mongodb.client.model.Filters +import blue.starry.stella.models.PicModel import kotlinx.coroutines.* +import org.litote.kmongo.eq import java.nio.file.Files import kotlin.time.minutes @@ -43,11 +44,11 @@ object MissingMediaRefetchWorker { } catch (e: PenicillinTwitterApiException) { when (e.error) { TwitterApiError.NoStatusFound, TwitterApiError.ResourceNotFound -> { - StellaMongoDBPicCollection.deleteOne(Filters.eq("url", pic.url)) + StellaMongoDBPicCollection.deleteOne(PicModel::url eq pic.url) logger.warn { "\"${pic.title}\" (${pic.url}) は削除されているため, エントリを削除しました。" } } TwitterApiError.SuspendedUser -> { - StellaMongoDBPicCollection.deleteOne(Filters.eq("url", pic.url)) + StellaMongoDBPicCollection.deleteOne(PicModel::url eq pic.url) logger.warn { "\"${pic.title}\" (${pic.url}) は作者が凍結されているため, エントリを削除しました。" } } TwitterApiError.CannotSeeProtectedStatus -> { From 411abed894813a134053873f64d9d78d49f2a11a Mon Sep 17 00:00:00 2001 From: Nep Date: Sat, 27 Feb 2021 23:13:12 +0900 Subject: [PATCH 08/19] Replace write with File#writeBytes --- .../kotlin/blue/starry/stella/worker/platform/NijieClient.kt | 4 +--- .../kotlin/blue/starry/stella/worker/platform/PixivClient.kt | 5 ++--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieClient.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieClient.kt index 246a0ba2..6b24bd46 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieClient.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieClient.kt @@ -142,8 +142,6 @@ class NijieClient(private val email: String, private val password: String) { header(HttpHeaders.Referrer, "https://nijie.info/") } - file.outputStream().use { - it.write(response) - } + file.writeBytes(response) } } diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivClient.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivClient.kt index a9ada20b..2a1d8031 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivClient.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivClient.kt @@ -117,8 +117,7 @@ class PixivClient(private val email: String, private val password: String) { header(HttpHeaders.Referrer, "https://app-api.pixiv.net/") } - file.outputStream().use { - it.write(response) - } + file.writeBytes(response) } } + From 296be82b8eae049d6d99882cb331a41110e6c655 Mon Sep 17 00:00:00 2001 From: Nep Date: Sat, 27 Feb 2021 23:39:13 +0900 Subject: [PATCH 09/19] Update paths --- Dockerfile | 1 + docs/CNAME | 1 - docs/static/script.js | 16 +++++----- src/jvmMain/kotlin/blue/starry/stella/App.kt | 29 ++++++++++++++----- .../blue/starry/stella/endpoints/GetMedia.kt | 2 +- ...tiveLevel.kt => PatchPicSensitiveLevel.kt} | 8 ++--- .../{PutRefresh.kt => PutPicRefresh.kt} | 8 ++--- .../endpoints/{EditTag.kt => UpdatePicTag.kt} | 6 ++-- 8 files changed, 42 insertions(+), 29 deletions(-) delete mode 100644 docs/CNAME rename src/jvmMain/kotlin/blue/starry/stella/endpoints/{PatchSensitiveLevel.kt => PatchPicSensitiveLevel.kt} (88%) rename src/jvmMain/kotlin/blue/starry/stella/endpoints/{PutRefresh.kt => PutPicRefresh.kt} (87%) rename src/jvmMain/kotlin/blue/starry/stella/endpoints/{EditTag.kt => UpdatePicTag.kt} (97%) diff --git a/Dockerfile b/Dockerfile index 9e36bfcf..ec8eaf0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,7 @@ RUN gradle -version > /dev/null \ # Final Stage FROM openjdk:17-jdk-alpine COPY --from=build /app/build/libs/stella-all.jar /app/stella.jar +COPY docs/ /app/docs/ WORKDIR /app ENTRYPOINT ["java", "-jar", "/app/stella.jar"] diff --git a/docs/CNAME b/docs/CNAME deleted file mode 100644 index 13a07e7f..00000000 --- a/docs/CNAME +++ /dev/null @@ -1 +0,0 @@ -stella.starry.blue \ No newline at end of file diff --git a/docs/static/script.js b/docs/static/script.js index 9d7198ce..039e0fbd 100644 --- a/docs/static/script.js +++ b/docs/static/script.js @@ -89,7 +89,7 @@ }; const App = { - mediaBaseUrl: "https://stella-api.starry.blue/media/", + mediaBaseUrl: "/api/media/", mobileWarningArea: document.getElementById("mobile-warning"), isMobile: navigator.userAgent.includes("iPhone OS") || navigator.userAgent.includes("Android") || navigator.userAgent.includes("Mobile"), countEntriesArea: document.getElementById("count-entries"), @@ -104,7 +104,7 @@ parameters.count = 10; const query = API.buildParameterString(parameters); - return `https://stella-api.starry.blue/query${query !== null ? "?" + query : ""}`; + return `/api/query${query !== null ? "?" + query : ""}`; }, responseType: "text", status: ".page-load-status", @@ -112,7 +112,7 @@ }); App.infiniteScroll.on("load", response => { - JSON.parse(response).result.map(t => (new PicElementBuilder(t)).build()); + JSON.parse(response).map(t => (new PicElementBuilder(t)).build()); const items = Container.element.querySelectorAll(".direction-reveal__card"); App.infiniteScroll.appendItems(items); @@ -1215,7 +1215,7 @@ return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); const query = API.buildParameterString(params); - xhr.open(method, `https://stella-api.starry.blue${path}${query !== null ? "?" + query : ""}`, true); + xhr.open(method, `/api${path}${query !== null ? "?" + query : ""}`, true); xhr.onload = () => { try { const json = JSON.parse(xhr.responseText); @@ -1248,10 +1248,10 @@ }).map(value => `${encodeURIComponent(value[0])}=${encodeURIComponent(value[1])}`).join("&"); }, summary: () => API.asyncRequest("GET", "/summary", null, null), - refreshEntry: pic => API.asyncRequest("PUT", `/refresh/${pic._id}`, null, null), - addTag: (pic, tag) => API.asyncRequest("PUT", `/edit/${pic._id}/tag`, null, {tag: tag}), - deleteTag: (pic, tag) => API.asyncRequest("DELETE", `/edit/${pic._id}/tag`, null, {tag: tag}), - updateSensitiveLevel: (pic, level) => API.asyncRequest("PATCH", `/edit/${pic._id}/sensitive_level`, null, {sensitive_level: level}), + refreshEntry: pic => API.asyncRequest("PUT", `/pic/${pic._id}/refresh`, null, null), + addTag: (pic, tag) => API.asyncRequest("PUT", `/pic/${pic._id}/tag`, null, {tag: tag}), + deleteTag: (pic, tag) => API.asyncRequest("DELETE", `/pic/${pic._id}/tag`, null, {tag: tag}), + updateSensitiveLevel: (pic, level) => API.asyncRequest("PATCH", `/pic/${pic._id}/sensitive_level`, null, {sensitive_level: level}), relationalTags: pic => API.asyncRequest("GET", "/query/tags", {id: pic._id, sensitive_level: pic.sensitive_level, count: 20}, null), searchTags: (pic, name) => API.asyncRequest("GET", "/query/tags", {id: pic._id, name: name, sensitive_level: pic.sensitive_level, count: 30}, null) }; diff --git a/src/jvmMain/kotlin/blue/starry/stella/App.kt b/src/jvmMain/kotlin/blue/starry/stella/App.kt index 00439413..c9323460 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/App.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/App.kt @@ -9,6 +9,7 @@ import blue.starry.stella.worker.platform.TwitterSourceProvider import io.ktor.application.* import io.ktor.features.* import io.ktor.http.* +import io.ktor.http.content.* import io.ktor.locations.* import io.ktor.routing.* import io.ktor.serialization.* @@ -19,6 +20,7 @@ import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import mu.KotlinLogging import org.litote.kmongo.id.serialization.IdKotlinXSerializationModule +import java.io.File import java.nio.file.Files import java.nio.file.Paths @@ -53,14 +55,25 @@ fun Application.module() { } routing { - getMedia() - getQuery() - getSummary() - putRefresh() - getQueryTags() - putEditTag() - deleteEditTag() - patchSensitiveLevel() + static("/") { + staticRootFolder = File("docs") + + static("static") { + files("static") + } + default("index.html") + } + + route("/api") { + getQuery() + getQueryTags() + getSummary() + getMediaByFilename() + putPicRefresh() + putPicTag() + deletePicTag() + patchPicSensitiveLevel() + } } install(XForwardedHeaderSupport) diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetMedia.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetMedia.kt index 31985142..3636d612 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetMedia.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetMedia.kt @@ -9,7 +9,7 @@ import io.ktor.routing.* @Location("/media/{filename}") data class GetMedia(val filename: String) -fun Route.getMedia() { +fun Route.getMediaByFilename() { get { (filename) -> call.respondFile(mediaDirectory.toFile(), filename) } diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchSensitiveLevel.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchPicSensitiveLevel.kt similarity index 88% rename from src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchSensitiveLevel.kt rename to src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchPicSensitiveLevel.kt index 7460dfd8..f8bdd456 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchSensitiveLevel.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchPicSensitiveLevel.kt @@ -15,11 +15,11 @@ import org.litote.kmongo.* import java.time.Instant import java.util.* -@Location("/edit/{id}/sensitive_level") -data class PatchEditSensitiveLevel(val id: String, val sensitive_level: Int) +@Location("/pic/{id}/sensitive_level") +data class PatchPicSensitiveLevel(val id: String, val sensitive_level: Int) -fun Route.patchSensitiveLevel() { - patch { param -> +fun Route.patchPicSensitiveLevel() { + patch { param -> if (param.sensitive_level !in 0..3) { return@patch call.respondApiError(HttpStatusCode.BadRequest) { "Essential \"sensitive_level\" is invalid." diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutRefresh.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutPicRefresh.kt similarity index 87% rename from src/jvmMain/kotlin/blue/starry/stella/endpoints/PutRefresh.kt rename to src/jvmMain/kotlin/blue/starry/stella/endpoints/PutPicRefresh.kt index 43e3e93d..cc7413a9 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutRefresh.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutPicRefresh.kt @@ -12,11 +12,11 @@ import io.ktor.routing.Route import org.litote.kmongo.eq import org.litote.kmongo.toId -@Location("/refresh/{id}") -data class PutRefresh(val id: String) +@Location("/pic/{id}/refresh") +data class PutPicRefresh(val id: String) -fun Route.putRefresh() { - put { (id) -> +fun Route.putPicRefresh() { + put { (id) -> val entry = StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId()) ?: return@put call.respondApiError(HttpStatusCode.NotFound) { "Specified entry is not found." diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/EditTag.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/UpdatePicTag.kt similarity index 97% rename from src/jvmMain/kotlin/blue/starry/stella/endpoints/EditTag.kt rename to src/jvmMain/kotlin/blue/starry/stella/endpoints/UpdatePicTag.kt index 71ccafb3..f14c0b23 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/EditTag.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/UpdatePicTag.kt @@ -14,10 +14,10 @@ import org.litote.kmongo.* import java.time.Instant import java.util.* -@Location("/edit/{id}/tag") +@Location("/pic/{id}/tag") data class EditTag(val id: String, val tag: String) -fun Route.putEditTag() { +fun Route.putPicTag() { put { param -> val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq param.id.toId()) ?: return@put call.respondApiError(HttpStatusCode.NotFound) { @@ -49,7 +49,7 @@ fun Route.putEditTag() { } } -fun Route.deleteEditTag() { +fun Route.deletePicTag() { delete { param -> val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq param.id.toId()) ?: return@delete call.respondApiError(HttpStatusCode.NotFound) { From e4dae1be3ad249fbf318d92879420c59ba54be34 Mon Sep 17 00:00:00 2001 From: Nep Date: Sat, 27 Feb 2021 23:55:46 +0900 Subject: [PATCH 10/19] Fix POST data handling --- .../endpoints/PatchPicSensitiveLevel.kt | 11 ++++++---- .../starry/stella/endpoints/UpdatePicTag.kt | 20 +++++++++++-------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchPicSensitiveLevel.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchPicSensitiveLevel.kt index f8bdd456..ff56033d 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchPicSensitiveLevel.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchPicSensitiveLevel.kt @@ -11,16 +11,19 @@ import io.ktor.locations.patch import io.ktor.request.* import io.ktor.response.* import io.ktor.routing.Route +import io.ktor.util.* import org.litote.kmongo.* import java.time.Instant import java.util.* @Location("/pic/{id}/sensitive_level") -data class PatchPicSensitiveLevel(val id: String, val sensitive_level: Int) +data class PatchPicSensitiveLevel(val id: String) fun Route.patchPicSensitiveLevel() { patch { param -> - if (param.sensitive_level !in 0..3) { + val sensitiveLevel = call.receiveParameters()["sensitive_level"]?.toIntOrNull() + + if (sensitiveLevel !in 0..3) { return@patch call.respondApiError(HttpStatusCode.BadRequest) { "Essential \"sensitive_level\" is invalid." } @@ -35,7 +38,7 @@ fun Route.patchPicSensitiveLevel() { StellaMongoDBPicCollection.updateOne( PicModel::_id eq param.id.toId(), combine( - setValue(PicModel::sensitive_level, param.sensitive_level), + setValue(PicModel::sensitive_level, sensitiveLevel), setValue(PicModel::timestamp / PicModel.Timestamp::manual_updated, Instant.now().toEpochMilli()) ) ) @@ -44,7 +47,7 @@ fun Route.patchPicSensitiveLevel() { call.respond(entry) logger.info { - "${entry.url} のエントリが更新されました。sensitive_level が ${param.sensitive_level} に変更されました。(${call.request.origin.remoteHost})" + "${entry.url} のエントリが更新されました。sensitive_level が $sensitiveLevel に変更されました。(${call.request.origin.remoteHost})" } } } diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/UpdatePicTag.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/UpdatePicTag.kt index f14c0b23..8ec80014 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/UpdatePicTag.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/UpdatePicTag.kt @@ -15,16 +15,18 @@ import java.time.Instant import java.util.* @Location("/pic/{id}/tag") -data class EditTag(val id: String, val tag: String) +data class EditTag(val id: String) fun Route.putPicTag() { put { param -> + val tag = call.receiveParameters()["tag"] ?: return@put + val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq param.id.toId()) ?: return@put call.respondApiError(HttpStatusCode.NotFound) { "Specified entry is not found." } - if (param.tag in oldEntry.tags.map { it.value }) { + if (tag in oldEntry.tags.map { it.value }) { return@put call.respondApiError(HttpStatusCode.BadRequest) { "Input tag already exists in database." } @@ -32,7 +34,7 @@ fun Route.putPicTag() { val updates = combine( addToSet(PicModel::tags, PicModel.Tag( - value = param.tag, + value = tag, user = call.request.origin.remoteHost, locked = false )), @@ -44,31 +46,33 @@ fun Route.putPicTag() { call.respond(entry) logger.info { - "${entry.url} のエントリが更新されました。「${param.tag}」が追加されました。(${call.request.origin.remoteHost})" + "${entry.url} のエントリが更新されました。「$tag」が追加されました。(${call.request.origin.remoteHost})" } } } fun Route.deletePicTag() { delete { param -> + val tag = call.receiveParameters()["tag"] ?: return@delete + val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq param.id.toId()) ?: return@delete call.respondApiError(HttpStatusCode.NotFound) { "Specified entry is not found." } - if (param.tag !in oldEntry.tags.map { it.value }) { + if (tag !in oldEntry.tags.map { it.value }) { return@delete call.respondApiError(HttpStatusCode.BadRequest) { "Input tag is not found in database." } } - if (param.tag in oldEntry.tags.filter { it.locked }.map { it.value }) { + if (tag in oldEntry.tags.filter { it.locked }.map { it.value }) { return@delete call.respondApiError(HttpStatusCode.BadRequest) { "Input tag is locked in database." } } - val tags = oldEntry.tags.filter { it.value != param.tag } + val tags = oldEntry.tags.filter { it.value != tag } val updates = combine( setValue(PicModel::tags, tags), setValue(PicModel::timestamp / PicModel.Timestamp::manual_updated, Instant.now().toEpochMilli()) @@ -79,7 +83,7 @@ fun Route.deletePicTag() { call.respond(entry) logger.info { - "${entry.url} のエントリが更新されました。「${param.tag}」が削除されました。(${call.request.origin.remoteHost})" + "${entry.url} のエントリが更新されました。「$tag」が削除されました。(${call.request.origin.remoteHost})" } } } From 889993efa78ce74402674261052e00fcb3338904 Mon Sep 17 00:00:00 2001 From: Nep Date: Sat, 27 Feb 2021 23:56:19 +0900 Subject: [PATCH 11/19] Set stella log definition --- src/jvmMain/kotlin/blue/starry/stella/App.kt | 12 ++++++++++++ src/jvmMain/resources/logback.xml | 2 ++ 2 files changed, 14 insertions(+) diff --git a/src/jvmMain/kotlin/blue/starry/stella/App.kt b/src/jvmMain/kotlin/blue/starry/stella/App.kt index c9323460..8cd5e81b 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/App.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/App.kt @@ -11,6 +11,7 @@ import io.ktor.features.* import io.ktor.http.* import io.ktor.http.content.* import io.ktor.locations.* +import io.ktor.request.* import io.ktor.routing.* import io.ktor.serialization.* import io.ktor.server.cio.* @@ -91,4 +92,15 @@ fun Application.module() { val host = Env.HOST ?: return@install host(host, schemes = listOf("http", "https")) } + + install(CallLogging) { + logger = KotlinLogging.logger("stella.web") + format { call -> + when (val status = call.response.status()) { + HttpStatusCode.Found -> "$status: ${call.request.toLogString()} -> ${call.response.headers[HttpHeaders.Location]}" + null -> "" + else -> "$status: ${call.request.httpMethod.value} ${call.request.uri}" + } + } + } } diff --git a/src/jvmMain/resources/logback.xml b/src/jvmMain/resources/logback.xml index e30141f8..e295c91f 100644 --- a/src/jvmMain/resources/logback.xml +++ b/src/jvmMain/resources/logback.xml @@ -5,6 +5,8 @@ + + From 7165cf8213d4cc3cb515ad4e1fd69c48a9f0b025 Mon Sep 17 00:00:00 2001 From: Nep Date: Sun, 28 Feb 2021 00:27:06 +0900 Subject: [PATCH 12/19] Fix id reference --- .../blue/starry/stella/endpoints/GetQueryTags.kt | 4 +++- .../stella/endpoints/PatchPicSensitiveLevel.kt | 8 +++++--- .../blue/starry/stella/endpoints/PutPicRefresh.kt | 7 ++++--- .../blue/starry/stella/endpoints/UpdatePicTag.kt | 14 ++++++++------ 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTags.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTags.kt index 4ea72eff..f3a54df3 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTags.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/GetQueryTags.kt @@ -10,9 +10,11 @@ import io.ktor.routing.* import kotlinx.coroutines.flow.flattenConcat import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList +import org.bson.types.ObjectId import org.litote.kmongo.and import org.litote.kmongo.coroutine.aggregate import org.litote.kmongo.eq +import org.litote.kmongo.id.toId import org.litote.kmongo.match import org.litote.kmongo.toId @@ -28,7 +30,7 @@ data class GetQueryTags( fun Route.getQueryTags() { get { param -> val existingTags = param.id?.let { id -> - StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId())?.tags + StellaMongoDBPicCollection.findOne(PicModel::_id eq ObjectId(id).toId())?.tags }?.map { it.value }.orEmpty() diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchPicSensitiveLevel.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchPicSensitiveLevel.kt index ff56033d..59cdb40b 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchPicSensitiveLevel.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PatchPicSensitiveLevel.kt @@ -12,7 +12,9 @@ import io.ktor.request.* import io.ktor.response.* import io.ktor.routing.Route import io.ktor.util.* +import org.bson.types.ObjectId import org.litote.kmongo.* +import org.litote.kmongo.id.toId import java.time.Instant import java.util.* @@ -29,21 +31,21 @@ fun Route.patchPicSensitiveLevel() { } } - if (StellaMongoDBPicCollection.countDocuments(PicModel::_id eq param.id.toId()) == 0L) { + if (StellaMongoDBPicCollection.countDocuments(PicModel::_id eq ObjectId(param.id).toId()) == 0L) { return@patch call.respondApiError(HttpStatusCode.NotFound) { "Specified entry is not found." } } StellaMongoDBPicCollection.updateOne( - PicModel::_id eq param.id.toId(), + PicModel::_id eq ObjectId(param.id).toId(), combine( setValue(PicModel::sensitive_level, sensitiveLevel), setValue(PicModel::timestamp / PicModel.Timestamp::manual_updated, Instant.now().toEpochMilli()) ) ) - val entry = StellaMongoDBPicCollection.findOne(PicModel::_id eq param.id.toId())!! + val entry = StellaMongoDBPicCollection.findOne(PicModel::_id eq ObjectId(param.id).toId())!! call.respond(entry) logger.info { diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutPicRefresh.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutPicRefresh.kt index cc7413a9..bb5c3535 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutPicRefresh.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/PutPicRefresh.kt @@ -9,15 +9,16 @@ import io.ktor.locations.* import io.ktor.locations.put import io.ktor.response.* import io.ktor.routing.Route +import org.bson.types.ObjectId import org.litote.kmongo.eq -import org.litote.kmongo.toId +import org.litote.kmongo.id.toId @Location("/pic/{id}/refresh") data class PutPicRefresh(val id: String) fun Route.putPicRefresh() { put { (id) -> - val entry = StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId()) + val entry = StellaMongoDBPicCollection.findOne(PicModel::_id eq ObjectId(id).toId()) ?: return@put call.respondApiError(HttpStatusCode.NotFound) { "Specified entry is not found." } @@ -27,7 +28,7 @@ fun Route.putPicRefresh() { "Unknown error occurred." } } else { - val newEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq id.toId())!! + val newEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq ObjectId(id).toId())!! call.respond(newEntry) } diff --git a/src/jvmMain/kotlin/blue/starry/stella/endpoints/UpdatePicTag.kt b/src/jvmMain/kotlin/blue/starry/stella/endpoints/UpdatePicTag.kt index 8ec80014..59f3835a 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/endpoints/UpdatePicTag.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/endpoints/UpdatePicTag.kt @@ -10,7 +10,9 @@ import io.ktor.locations.* import io.ktor.request.* import io.ktor.response.* import io.ktor.routing.Route +import org.bson.types.ObjectId import org.litote.kmongo.* +import org.litote.kmongo.id.toId import java.time.Instant import java.util.* @@ -21,7 +23,7 @@ fun Route.putPicTag() { put { param -> val tag = call.receiveParameters()["tag"] ?: return@put - val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq param.id.toId()) + val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq ObjectId(param.id).toId()) ?: return@put call.respondApiError(HttpStatusCode.NotFound) { "Specified entry is not found." } @@ -40,9 +42,9 @@ fun Route.putPicTag() { )), setValue(PicModel::timestamp / PicModel.Timestamp::manual_updated, Instant.now().toEpochMilli()) ) - StellaMongoDBPicCollection.updateOne(PicModel::_id eq param.id.toId(), updates) + StellaMongoDBPicCollection.updateOne(PicModel::_id eq ObjectId(param.id).toId(), updates) - val entry = StellaMongoDBPicCollection.findOne(PicModel::_id eq param.id.toId())!! + val entry = StellaMongoDBPicCollection.findOne(PicModel::_id eq ObjectId(param.id).toId())!! call.respond(entry) logger.info { @@ -55,7 +57,7 @@ fun Route.deletePicTag() { delete { param -> val tag = call.receiveParameters()["tag"] ?: return@delete - val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq param.id.toId()) + val oldEntry = StellaMongoDBPicCollection.findOne(PicModel::_id eq ObjectId(param.id).toId()) ?: return@delete call.respondApiError(HttpStatusCode.NotFound) { "Specified entry is not found." } @@ -77,9 +79,9 @@ fun Route.deletePicTag() { setValue(PicModel::tags, tags), setValue(PicModel::timestamp / PicModel.Timestamp::manual_updated, Instant.now().toEpochMilli()) ) - StellaMongoDBPicCollection.updateOne(PicModel::_id eq param.id.toId(), updates) + StellaMongoDBPicCollection.updateOne(PicModel::_id eq ObjectId(param.id).toId(), updates) - val entry = StellaMongoDBPicCollection.findOne(PicModel::_id eq param.id.toId())!! + val entry = StellaMongoDBPicCollection.findOne(PicModel::_id eq ObjectId(param.id).toId())!! call.respond(entry) logger.info { From 90a4277b50cdfbad7922c6ab7627c1f895f2e615 Mon Sep 17 00:00:00 2001 From: Nep Date: Sun, 28 Feb 2021 00:33:59 +0900 Subject: [PATCH 13/19] Fix null type --- src/jvmMain/kotlin/blue/starry/stella/models/PicModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jvmMain/kotlin/blue/starry/stella/models/PicModel.kt b/src/jvmMain/kotlin/blue/starry/stella/models/PicModel.kt index e0aed06a..c5da3d4f 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/models/PicModel.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/models/PicModel.kt @@ -34,8 +34,8 @@ data class PicModel( data class Timestamp( val created: Long, val added: Long, - val manual_updated: Long, - val auto_updated: Long + val manual_updated: Long?, + val auto_updated: Long? ) @Serializable From 9015f437f98065278c54fd5572726ce69143e2a5 Mon Sep 17 00:00:00 2001 From: Nep Date: Sun, 28 Feb 2021 00:35:55 +0900 Subject: [PATCH 14/19] Revert to use GlobalScope --- src/jvmMain/kotlin/blue/starry/stella/App.kt | 12 +++++------- .../stella/worker/MissingMediaRefetchWorker.kt | 4 ++-- .../blue/starry/stella/worker/RefreshWorker.kt | 4 ++-- .../stella/worker/platform/NijieSourceProvider.kt | 6 +++--- .../stella/worker/platform/PixivSourceProvider.kt | 6 +++--- .../stella/worker/platform/TwitterSourceProvider.kt | 10 ++++++---- 6 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/jvmMain/kotlin/blue/starry/stella/App.kt b/src/jvmMain/kotlin/blue/starry/stella/App.kt index 8cd5e81b..9904c47f 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/App.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/App.kt @@ -33,14 +33,12 @@ fun main() { Files.createDirectory(mediaDirectory) } - GlobalScope.launch { - RefreshWorker.start() - MissingMediaRefetchWorker.start() + RefreshWorker.start() + MissingMediaRefetchWorker.start() - TwitterSourceProvider.start() - PixivSourceProvider.start() - NijieSourceProvider.start() - } + TwitterSourceProvider.start() + PixivSourceProvider.start() + NijieSourceProvider.start() embeddedServer(CIO, host = Env.HTTP_HOST, port = Env.HTTP_PORT, module = Application::module).start(wait = true) } diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/MissingMediaRefetchWorker.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/MissingMediaRefetchWorker.kt index de1b4bbb..423827cc 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/MissingMediaRefetchWorker.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/MissingMediaRefetchWorker.kt @@ -11,8 +11,8 @@ import java.nio.file.Files import kotlin.time.minutes object MissingMediaRefetchWorker { - suspend fun start(): Unit = coroutineScope { - launch { + fun start() { + GlobalScope.launch { while (isActive) { try { check() diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/RefreshWorker.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/RefreshWorker.kt index b3d13c81..4eb232c8 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/RefreshWorker.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/RefreshWorker.kt @@ -13,8 +13,8 @@ import kotlin.time.minutes import kotlin.time.seconds object RefreshWorker { - suspend fun start(): Unit = coroutineScope { - launch { + fun start() { + GlobalScope.launch { while (isActive) { try { check() diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieSourceProvider.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieSourceProvider.kt index a61b4553..6ef79f2f 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieSourceProvider.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieSourceProvider.kt @@ -10,10 +10,10 @@ import kotlinx.coroutines.* import kotlin.time.minutes object NijieSourceProvider { - suspend fun start(): Unit = coroutineScope { - val client = StellaNijieClient ?: return@coroutineScope + fun start() { + val client = StellaNijieClient ?: return - launch { + GlobalScope.launch { while (isActive) { try { fetchBookmark(client) diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivSourceProvider.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivSourceProvider.kt index 76129e4b..8cb98c6e 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivSourceProvider.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivSourceProvider.kt @@ -16,10 +16,10 @@ import kotlin.time.minutes object PixivSourceProvider { private val requestedIds = CopyOnWriteArrayList() - suspend fun start(): Unit = coroutineScope { - val client = StellaPixivClient ?: return@coroutineScope + fun start() { + val client = StellaPixivClient ?: return - launch { + GlobalScope.launch { while (isActive) { try { fetchBookmark(client, false) diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/TwitterSourceProvider.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/TwitterSourceProvider.kt index 510daa7c..fdd01929 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/TwitterSourceProvider.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/TwitterSourceProvider.kt @@ -18,7 +18,9 @@ import blue.starry.penicillin.extensions.execute import blue.starry.penicillin.extensions.idObj import blue.starry.penicillin.extensions.models.text import blue.starry.penicillin.models.Status -import blue.starry.stella.* +import blue.starry.stella.Env +import blue.starry.stella.logger +import blue.starry.stella.mediaDirectory import blue.starry.stella.worker.MediaRegister import blue.starry.stella.worker.StellaHttpClient import blue.starry.stella.worker.StellaTwitterClient @@ -29,10 +31,10 @@ import kotlin.time.minutes object TwitterSourceProvider { private val tweetUrlPattern = "^(?:http(?:s)?://)?(?:m|mobile)?twitter\\.com/(?:\\w|_)+?/status/(\\d+)".toRegex() - suspend fun start(): Unit = coroutineScope { - val client = StellaTwitterClient ?: return@coroutineScope + fun start() { + val client = StellaTwitterClient ?: return - launch { + GlobalScope.launch { while (isActive) { try { fetchTimeline(client) From 71b7902191a2544b5fb2bcd1a7b59205312d74bc Mon Sep 17 00:00:00 2001 From: Nep Date: Sun, 28 Feb 2021 00:49:57 +0900 Subject: [PATCH 15/19] Add HttpClient logging --- build.gradle.kts | 4 ++-- .../kotlin/blue/starry/stella/worker/Client.kt | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index e75f9f8f..b30312a2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -26,6 +26,7 @@ object Libraries { const val KtorLocations = "io.ktor:ktor-locations:${Versions.Ktor}" const val KtorSerialization = "io.ktor:ktor-serialization:${Versions.Ktor}" const val KtorClientCIO = "io.ktor:ktor-client-cio:${Versions.Ktor}" + const val KtorClientLogging = "io.ktor:ktor-client-logging:${Versions.Ktor}" const val KMongoCoroutineSerialization = "org.litote.kmongo:kmongo-coroutine-serialization:${Versions.KMongo}" const val KMongoIdSerialization = "org.litote.kmongo:kmongo-id-serialization:${Versions.KMongo}" @@ -84,12 +85,11 @@ kotlin { named("jvmMain") { dependencies { - implementation(kotlin("reflect")) - implementation(Libraries.KtorServerCIO) implementation(Libraries.KtorLocations) implementation(Libraries.KtorSerialization) implementation(Libraries.KtorClientCIO) + implementation(Libraries.KtorClientLogging) implementation(Libraries.KMongoCoroutineSerialization) implementation(Libraries.KMongoIdSerialization) diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/Client.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/Client.kt index 8a22b64a..e1fc4c1a 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/Client.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/Client.kt @@ -13,12 +13,25 @@ import blue.starry.stella.worker.platform.PixivClient import io.ktor.client.* import io.ktor.client.engine.cio.* import io.ktor.client.features.cookies.* +import io.ktor.client.features.logging.* +import mu.KotlinLogging import org.litote.kmongo.coroutine.coroutine import org.litote.kmongo.reactivestreams.KMongo val StellaHttpClient by lazy { HttpClient(CIO) { install(HttpCookies) + + Logging { + level = LogLevel.INFO + logger = object : Logger { + private val logger = KotlinLogging.logger("stella.http") + + override fun log(message: String) { + logger.trace { message } + } + } + } } } From 37a4fc7e539da02c70dcc9e4489124ea04ed1bdc Mon Sep 17 00:00:00 2001 From: Nep Date: Sun, 28 Feb 2021 02:32:07 +0900 Subject: [PATCH 16/19] Implement REFRESH_TOKEN based Pixiv Auth https://github.com/upbit/pixivpy/issues/158 --- README.md | 3 +- src/jvmMain/kotlin/blue/starry/stella/Env.kt | 3 +- .../blue/starry/stella/worker/Client.kt | 9 +-- .../stella/worker/platform/PixivClient.kt | 79 ++++++++++--------- .../worker/platform/PixivSourceProvider.kt | 1 + 5 files changed, 48 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index ecb296d3..25cbe378 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,7 @@ services: TWITTER_CS: xxx TWITTER_AT: xxx TWITTER_ATS: xxx - PIXIV_EMAIL: xxx - PIXIV_PASSWORD: xxx + PIXIV_REFRESH_TOKEN: xxx NIJIE_EMAIL: xxx NIJIE_PASSWORD: xxx diff --git a/src/jvmMain/kotlin/blue/starry/stella/Env.kt b/src/jvmMain/kotlin/blue/starry/stella/Env.kt index e293846e..44684b1d 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/Env.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/Env.kt @@ -21,8 +21,7 @@ object Env { val TWITTER_CS by stringOrNull val TWITTER_AT by stringOrNull val TWITTER_ATS by stringOrNull - val PIXIV_EMAIL by stringOrNull - val PIXIV_PASSWORD by stringOrNull + val PIXIV_REFRESH_TOKEN by stringOrNull val NIJIE_EMAIL by stringOrNull val NIJIE_PASSWORD by stringOrNull } diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/Client.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/Client.kt index e1fc4c1a..210f69d8 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/Client.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/Client.kt @@ -23,7 +23,7 @@ val StellaHttpClient by lazy { install(HttpCookies) Logging { - level = LogLevel.INFO + level = LogLevel.ALL logger = object : Logger { private val logger = KotlinLogging.logger("stella.http") @@ -36,12 +36,9 @@ val StellaHttpClient by lazy { } val StellaPixivClient by lazy { - val (email, password) = Env.PIXIV_EMAIL to Env.PIXIV_PASSWORD - if (email == null || password == null) { - return@lazy null - } + val token = Env.PIXIV_REFRESH_TOKEN ?: return@lazy null - PixivClient(email, password) + PixivClient(token) } val StellaTwitterClient by lazy { diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivClient.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivClient.kt index 2a1d8031..b602169a 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivClient.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivClient.kt @@ -10,56 +10,49 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.io.File import java.security.MessageDigest -import java.time.Instant +import java.time.OffsetDateTime +import java.time.ZoneId import java.time.format.DateTimeFormatter -import java.util.* -class PixivClient(private val email: String, private val password: String) { - private val lock = Mutex() +class PixivClient(private val refreshToken: String) { + private val mutex = Mutex() private var token: PixivModel.Token? = null - private val isLoggedIn: Boolean - get() = token != null - - private val dateFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ", Locale.ENGLISH) - private fun HttpRequestBuilder.setHeaders(credentials: Boolean) { - DateTimeFormatter.ISO_DATE_TIME - val time = dateFormat.format(Instant.now()) - - userAgent("PixivAndroidApp/5.0.64 (Android 6.0)") - header("X-Client-Time", time) - header("X-Client-Hash", MessageDigest.getInstance("MD5").digest( - (time + "28c1fdd170a5204386cb1313c7077b34f83e4aaf4aa829ce78c231e05b0bae2c").toByteArray() - ).joinToString("") { - "%02x".format(it) - }) - - if (credentials) { - requireNotNull(token) - header(HttpHeaders.Authorization, "Bearer ${token?.response?.accessToken}") + private suspend fun isLoggedIn(): Boolean { + return mutex.withLock { + token != null } } private suspend fun login() { - if (isLoggedIn) { + if (isLoggedIn()) { return } val parameters = Parameters.build { - append("grant_type", "password") - append("client_id", "MOBrBDS8blbauoSck0ZfDbtuzpyT") - append("username", email) append("get_secure_url", "1") - append("password", password) + append("client_id", "MOBrBDS8blbauoSck0ZfDbtuzpyT") append("client_secret", "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj") + append("grant_type", "refresh_token") + append("refresh_token", refreshToken) } - token = lock.withLock(token) { + token = mutex.withLock { StellaHttpClient.submitForm(parameters) { url("https://oauth.secure.pixiv.net/auth/token") - - setHeaders(false) + userAgent("PixivAndroidApp/5.0.234 (Android 11; Pixel 5)") + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssxxx") + val time = OffsetDateTime.now(ZoneId.of("UTC")).format(formatter) + + header("X-Client-Time", time) + header("X-Client-Hash", MessageDigest.getInstance("MD5").digest( + (time + "28c1fdd170a5204386cb1313c7077b34f83e4aaf4aa829ce78c231e05b0bae2c").toByteArray() + ).joinToString("") { + "%02x".format(it) + }) }.parseObject { + logger.trace { it } + PixivModel.Token(it) } } @@ -68,7 +61,7 @@ class PixivClient(private val email: String, private val password: String) { } suspend fun logout() { - lock.withLock { + mutex.withLock { token = null } } @@ -80,7 +73,7 @@ class PixivClient(private val email: String, private val password: String) { parameter("user_id", token?.response?.user?.id) parameter("restrict", if (private) "private" else "public") - setHeaders(true) + setHeaders() }.parseObject { PixivModel.Bookmark(it) } @@ -94,8 +87,8 @@ class PixivClient(private val email: String, private val password: String) { append("illust_id", id.toString()) } - return StellaHttpClient.submitForm("https://app-api.pixiv.net/v2/illust/bookmark/add", parameters) { - setHeaders(true) + StellaHttpClient.submitForm("https://app-api.pixiv.net/v2/illust/bookmark/add", parameters) { + setHeaders() } } @@ -106,8 +99,8 @@ class PixivClient(private val email: String, private val password: String) { append("illust_id", id.toString()) } - return StellaHttpClient.submitForm("https://app-api.pixiv.net/v1/illust/bookmark/delete", parameters) { - setHeaders(true) + StellaHttpClient.submitForm("https://app-api.pixiv.net/v1/illust/bookmark/delete", parameters) { + setHeaders() } } @@ -119,5 +112,17 @@ class PixivClient(private val email: String, private val password: String) { file.writeBytes(response) } + + private fun HttpRequestBuilder.setHeaders(requireAuth: Boolean = true) { + header("App-OS", "ios") + header("App-OS-Version", "12.2") + header("App-Version", "7.6.2") + userAgent("PixivIOSApp/7.6.2 (iOS 12.2; iPhone9,1)") + + if (requireAuth) { + val token = token?.response?.accessToken ?: error("Login required.") + header(HttpHeaders.Authorization, "Bearer $token") + } + } } diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivSourceProvider.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivSourceProvider.kt index 8cb98c6e..c868b22e 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivSourceProvider.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivSourceProvider.kt @@ -28,6 +28,7 @@ object PixivSourceProvider { break } catch (e: ResponseException) { client.logout() + logger.error(e) { "PixivSource でリクエストに失敗しました。ログインを試行します。" } } catch (e: Throwable) { logger.error(e) { "PixivSource で例外が発生しました。" } } From b7f7a2b441b568e9252d885cd4f7eb338475a028 Mon Sep 17 00:00:00 2001 From: Nep Date: Sun, 28 Feb 2021 02:37:20 +0900 Subject: [PATCH 17/19] Refresh last token --- .../kotlin/blue/starry/stella/worker/platform/PixivClient.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivClient.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivClient.kt index b602169a..9acc3af5 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivClient.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivClient.kt @@ -34,7 +34,7 @@ class PixivClient(private val refreshToken: String) { append("client_id", "MOBrBDS8blbauoSck0ZfDbtuzpyT") append("client_secret", "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj") append("grant_type", "refresh_token") - append("refresh_token", refreshToken) + append("refresh_token", token?.response?.refreshToken ?: refreshToken) } token = mutex.withLock { From 167e51450becc2adace84c0db5a0e427f6cf5c1c Mon Sep 17 00:00:00 2001 From: Nep Date: Sun, 28 Feb 2021 03:06:27 +0900 Subject: [PATCH 18/19] Refactoring --- src/jvmMain/kotlin/blue/starry/stella/App.kt | 2 -- .../kotlin/blue/starry/stella/worker/Client.kt | 2 +- .../stella/worker/platform/PixivSourceProvider.kt | 5 +---- .../stella/worker/platform/TwitterSourceProvider.kt | 13 +++++++------ 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/jvmMain/kotlin/blue/starry/stella/App.kt b/src/jvmMain/kotlin/blue/starry/stella/App.kt index 9904c47f..2d8b80ae 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/App.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/App.kt @@ -16,8 +16,6 @@ import io.ktor.routing.* import io.ktor.serialization.* import io.ktor.server.cio.* import io.ktor.server.engine.* -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import mu.KotlinLogging import org.litote.kmongo.id.serialization.IdKotlinXSerializationModule diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/Client.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/Client.kt index 210f69d8..7245e59f 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/Client.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/Client.kt @@ -23,7 +23,7 @@ val StellaHttpClient by lazy { install(HttpCookies) Logging { - level = LogLevel.ALL + level = LogLevel.INFO logger = object : Logger { private val logger = KotlinLogging.logger("stella.http") diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivSourceProvider.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivSourceProvider.kt index c868b22e..2349b2ce 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivSourceProvider.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/PixivSourceProvider.kt @@ -5,7 +5,6 @@ import blue.starry.stella.logger import blue.starry.stella.mediaDirectory import blue.starry.stella.worker.MediaRegister import blue.starry.stella.worker.StellaPixivClient -import io.ktor.client.features.* import kotlinx.coroutines.* import java.time.ZonedDateTime import java.time.format.DateTimeFormatter @@ -26,10 +25,8 @@ object PixivSourceProvider { fetchBookmark(client, true) } catch (e: CancellationException) { break - } catch (e: ResponseException) { - client.logout() - logger.error(e) { "PixivSource でリクエストに失敗しました。ログインを試行します。" } } catch (e: Throwable) { + client.logout() logger.error(e) { "PixivSource で例外が発生しました。" } } diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/TwitterSourceProvider.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/TwitterSourceProvider.kt index fdd01929..cdcacd88 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/TwitterSourceProvider.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/TwitterSourceProvider.kt @@ -30,6 +30,7 @@ import kotlin.time.minutes object TwitterSourceProvider { private val tweetUrlPattern = "^(?:http(?:s)?://)?(?:m|mobile)?twitter\\.com/(?:\\w|_)+?/status/(\\d+)".toRegex() + private val tcoUrlPattern = "https://t\\.co/[a-zA-Z0-9]+".toRegex() fun start() { val client = StellaTwitterClient ?: return @@ -52,6 +53,7 @@ object TwitterSourceProvider { private suspend fun fetchTimeline(client: ApiClient) { val timeline = client.timeline.userTimeline().execute() + for (status in timeline) { if (status.retweetedStatus != null) { // RT を処理 @@ -80,6 +82,7 @@ object TwitterSourceProvider { if (!status.user.following) { client.friendships.createByUserId(userId = status.user.id).execute() } + client.favorites.destroy(id = status.id).execute() } } @@ -93,7 +96,7 @@ object TwitterSourceProvider { register(status.result, user, auto) } - private val tcoRegex = "https://t\\.co/[a-zA-Z0-9]+".toRegex() + private suspend fun register(status: Status, user: String?, auto: Boolean) { val media = status.extendedEntities?.media ?: status.entities.media if (media.isEmpty()) { @@ -102,7 +105,7 @@ object TwitterSourceProvider { val entry = MediaRegister.Entry( title = "${status.text.take(20)}...", - description = status.text.replace(tcoRegex) { + description = status.text.replace(tcoUrlPattern) { "
${it.value}" }, url = "https://twitter.com/${status.user.screenName}/status/${status.idStr}", @@ -120,10 +123,8 @@ object TwitterSourceProvider { val file = mediaDirectory.resolve("twitter_${status.idStr}_$i.$ext").toFile() if (!file.exists()) { - file.outputStream().use { - val response = StellaHttpClient.get(url) - it.write(response) - } + val response = StellaHttpClient.get(url) + file.writeBytes(response) } MediaRegister.Entry.Picture(i, "twitter_${status.idStr}_$i.$ext", url, ext) From e0aa888c25129fcee98cfe05864ec5c728733951 Mon Sep 17 00:00:00 2001 From: Nep Date: Sun, 28 Feb 2021 03:06:35 +0900 Subject: [PATCH 19/19] Fix Nijie login --- .../stella/worker/platform/NijieClient.kt | 84 ++++++++++++------- .../worker/platform/NijieSourceProvider.kt | 11 +-- 2 files changed, 55 insertions(+), 40 deletions(-) diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieClient.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieClient.kt index 6b24bd46..f9827320 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieClient.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieClient.kt @@ -4,11 +4,12 @@ import blue.starry.jsonkt.parseObject import blue.starry.jsonkt.toJsonObject import blue.starry.stella.logger import blue.starry.stella.worker.StellaHttpClient +import io.ktor.client.features.* import io.ktor.client.request.* import io.ktor.client.request.forms.* -import io.ktor.client.statement.* import io.ktor.http.* -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonPrimitive import org.jsoup.Jsoup @@ -19,34 +20,29 @@ import java.time.format.DateTimeFormatter import java.util.* class NijieClient(private val email: String, private val password: String) { - var isLoggedIn = checkSession() - private set + private val mutex = Mutex() + private var loggedIn = false - private fun checkSession(): Boolean { - return runCatching { - runBlocking { - StellaHttpClient.get("https://nijie.info") { - setHeaders() - }.execute { - it.call.request.url.encodedPath == "/" - } - } - }.getOrNull() ?: false + private suspend fun isLoggedIn(): Boolean { + return mutex.withLock { + loggedIn + } } - private fun HttpRequestBuilder.setHeaders() { - header(HttpHeaders.AcceptLanguage, "ja") - userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.80 Safari/537.36") - } + private suspend fun login() { + StellaHttpClient.get("https://nijie.info") { + setHeaders() + expectSuccess = false + } - suspend fun login() { val parameters = Parameters.build { append("email", email) append("password", password) append("save", "on") - val jsoup = StellaHttpClient.get("https://nijie.info/login.php") { + val jsoup = StellaHttpClient.get("https://nijie.info/age_jump.php?url=") { setHeaders() + expectSuccess = false }.let { Jsoup.parse(it) } @@ -54,17 +50,28 @@ class NijieClient(private val email: String, private val password: String) { append("url", jsoup.select("input[name=url]").attr("value")) } - StellaHttpClient.submitForm(parameters) { + StellaHttpClient.submitForm(parameters) { url("https://nijie.info/login_int.php") setHeaders() - header(HttpHeaders.Referrer, "https://nijie.info/login.php") - }.execute() + header(HttpHeaders.Referrer, "https://nijie.info/login.php?url=$url") + expectSuccess = false + } - isLoggedIn = true + loggedIn = true logger.info { "Nijie にログインしました。" } } + suspend fun logout() { + mutex.withLock { + loggedIn = false + } + } + suspend fun bookmarks(page: Int = 1): List { + if (!isLoggedIn()) { + login() + } + val html = StellaHttpClient.get("https://nijie.info/okiniiri.php?p=$page") { setHeaders() } @@ -78,26 +85,34 @@ class NijieClient(private val email: String, private val password: String) { } suspend fun deleteBookmark(id: String) { - val jsoup = StellaHttpClient.get("https://nijie.info/bookmark_edit.php?id=$id") { + if (!isLoggedIn()) { + login() + } + + val html = StellaHttpClient.get("https://nijie.info/bookmark_edit.php?id=$id") { setHeaders() }.let { Jsoup.parse(it) } - val key = jsoup.select("input[value=$id]").last().attr("name") - + val key = html.select("input[value=$id]").last().attr("name") val parameters = Parameters.build { append(key, id) } - StellaHttpClient.submitForm(parameters) { + StellaHttpClient.submitForm(parameters) { url("https://nijie.info/bookmark_delete.php") setHeaders() - }.execute() + expectSuccess = false + } } private val formatter = DateTimeFormatter.ofPattern("EEE MMM d HH:mm:ss yyyy", Locale.ENGLISH) suspend fun picture(id: String): NijieModel.Picture { + if (!isLoggedIn()) { + login() + } + val jsoup = StellaHttpClient.get("https://nijie.info/view.php?id=$id") { setHeaders() }.let { @@ -122,6 +137,10 @@ class NijieClient(private val email: String, private val password: String) { } private suspend fun viewCount(id: String): Int { + if (!isLoggedIn()) { + login() + } + val parameters = Parameters.build { append("id", id) } @@ -133,7 +152,7 @@ class NijieClient(private val email: String, private val password: String) { header("X-Requested-With", "XMLHttpRequest") setHeaders() }.toJsonObject()["view_count"]!!.jsonPrimitive.int - }.getOrNull() ?: 0 + }.getOrDefault(0) } suspend fun download(url: String, file: File) { @@ -144,4 +163,9 @@ class NijieClient(private val email: String, private val password: String) { file.writeBytes(response) } + + private fun HttpRequestBuilder.setHeaders() { + header(HttpHeaders.AcceptLanguage, "ja") + userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.80 Safari/537.36") + } } diff --git a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieSourceProvider.kt b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieSourceProvider.kt index 6ef79f2f..1dfb4ea8 100644 --- a/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieSourceProvider.kt +++ b/src/jvmMain/kotlin/blue/starry/stella/worker/platform/NijieSourceProvider.kt @@ -5,7 +5,6 @@ import blue.starry.stella.logger import blue.starry.stella.mediaDirectory import blue.starry.stella.worker.MediaRegister import blue.starry.stella.worker.StellaNijieClient -import io.ktor.client.features.* import kotlinx.coroutines.* import kotlin.time.minutes @@ -19,12 +18,8 @@ object NijieSourceProvider { fetchBookmark(client) } catch (e: CancellationException) { break - } catch (e: ResponseException) { - // セッション切れ - if (client.isLoggedIn) { - client.login() - } } catch (e: Throwable) { + client.logout() logger.error(e) { "NijieSource で例外が発生しました。" } } @@ -34,10 +29,6 @@ object NijieSourceProvider { } private suspend fun fetchBookmark(client: NijieClient) { - if (!client.isLoggedIn) { - client.login() - } - for (bookmark in client.bookmarks().reversed()) { val picture = client.picture(bookmark.id) register(client, picture, "User", false)