Add Anchira (#162)
* Add Anchira * Encode API decryption key * Apply corrections * Remove unused MessagePack library
This commit is contained in:
parent
6cc9041f10
commit
cafe12c736
2
src/en/anchira/AndroidManifest.xml
Normal file
2
src/en/anchira/AndroidManifest.xml
Normal file
@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
13
src/en/anchira/build.gradle
Normal file
13
src/en/anchira/build.gradle
Normal file
@ -0,0 +1,13 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlinx-serialization'
|
||||
|
||||
ext {
|
||||
extName = 'Anchira'
|
||||
pkgNameSuffix = 'en.anchira'
|
||||
extClass = '.Anchira'
|
||||
extVersionCode = 1
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
BIN
src/en/anchira/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
src/en/anchira/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.4 KiB |
BIN
src/en/anchira/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
src/en/anchira/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.4 KiB |
BIN
src/en/anchira/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
src/en/anchira/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
BIN
src/en/anchira/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
src/en/anchira/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 31 KiB |
BIN
src/en/anchira/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
src/en/anchira/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 52 KiB |
BIN
src/en/anchira/res/web_hi_res_512.png
Normal file
BIN
src/en/anchira/res/web_hi_res_512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 442 KiB |
@ -0,0 +1,337 @@
|
||||
package eu.kanade.tachiyomi.extension.en.anchira
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.kanade.tachiyomi.extension.en.anchira.AnchiraHelper.decodeBytes
|
||||
import eu.kanade.tachiyomi.extension.en.anchira.AnchiraHelper.getPathFromUrl
|
||||
import eu.kanade.tachiyomi.extension.en.anchira.AnchiraHelper.json
|
||||
import eu.kanade.tachiyomi.extension.en.anchira.AnchiraHelper.prepareTags
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class Anchira : HttpSource(), ConfigurableSource {
|
||||
override val name = "Anchira"
|
||||
|
||||
override val baseUrl = "https://anchira.to"
|
||||
|
||||
private val apiUrl = "$baseUrl/api/v1"
|
||||
|
||||
private val libraryUrl = "$apiUrl/library"
|
||||
|
||||
private val cdnUrl = "https://kisakisexo.xyz"
|
||||
|
||||
override val lang = "en"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||
.rateLimit(3, 1, TimeUnit.SECONDS)
|
||||
.addInterceptor { apiInterceptor(it) }
|
||||
.build()
|
||||
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder().add("X-Requested-With", "XMLHttpRequest")
|
||||
|
||||
// Latest
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = GET("$libraryUrl?page=$page", headers)
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val data = decodeBytes<LibraryResponse>(response.body)
|
||||
|
||||
return MangasPage(
|
||||
data.entries.map {
|
||||
SManga.create().apply {
|
||||
url = "/g/${it.id}/${it.key}"
|
||||
title = it.title
|
||||
thumbnail_url = "$cdnUrl/${it.id}/${it.key}/m/${it.cover.name}"
|
||||
artist = it.tags.filter { it.namespace == 1 }.joinToString(", ") { it.name }
|
||||
author = it.tags.filter { it.namespace == 2 }.joinToString(", ") { it.name }
|
||||
genre = prepareTags(it.tags)
|
||||
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
|
||||
status = SManga.COMPLETED
|
||||
}
|
||||
}.toList(),
|
||||
(data.page + 1) * data.limit < data.total,
|
||||
)
|
||||
}
|
||||
|
||||
// Popular
|
||||
|
||||
override fun popularMangaRequest(page: Int) = GET("$libraryUrl?sort=32&page=$page", headers)
|
||||
|
||||
override fun popularMangaParse(response: Response) = latestUpdatesParse(response)
|
||||
|
||||
// Search
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
var url = libraryUrl.toHttpUrl().newBuilder()
|
||||
|
||||
url.addQueryParameter("page", page.toString())
|
||||
|
||||
if (query.isNotBlank()) {
|
||||
url.addQueryParameter("s", query)
|
||||
}
|
||||
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is CategoryGroup -> {
|
||||
var sum = 0
|
||||
|
||||
filter.state.forEach { category ->
|
||||
when (category.name) {
|
||||
"Manga" -> if (category.state) sum = sum or 1
|
||||
"Doujinshi" -> if (category.state) sum = sum or 2
|
||||
"Illustration" -> if (category.state) sum = sum or 4
|
||||
}
|
||||
}
|
||||
|
||||
if (sum > 0) url.addQueryParameter("cat", sum.toString())
|
||||
}
|
||||
|
||||
is SortFilter -> {
|
||||
val sort = when (filter.state?.index) {
|
||||
0 -> "1"
|
||||
1 -> "2"
|
||||
2 -> "4"
|
||||
4 -> "32"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
if (sort.isNotEmpty()) url.addQueryParameter("sort", sort)
|
||||
if (filter.state?.ascending == true) url.addQueryParameter("order", "1")
|
||||
}
|
||||
|
||||
is FavoritesFilter -> {
|
||||
if (filter.state) {
|
||||
if (!isLoggedIn()) {
|
||||
throw IOException("No login cookie found")
|
||||
}
|
||||
|
||||
url = url.toString().replace("library", "user/favorites").toHttpUrl()
|
||||
.newBuilder()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response) = latestUpdatesParse(response)
|
||||
|
||||
// Details
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga) =
|
||||
GET("$libraryUrl/${getPathFromUrl(manga.url)}", headers)
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val data = decodeBytes<Entry>(response.body)
|
||||
|
||||
return SManga.create().apply {
|
||||
url = "/g/${data.id}/${data.key}"
|
||||
title = data.title
|
||||
thumbnail_url =
|
||||
"$cdnUrl/${data.id}/${data.key}/b/${data.data[data.thumbnailIndex].name}"
|
||||
artist = data.tags.filter { it.namespace == 1 }.joinToString(", ") { it.name }
|
||||
author = data.tags.filter { it.namespace == 2 }.joinToString(", ") { it.name }
|
||||
genre = prepareTags(data.tags)
|
||||
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
|
||||
status = SManga.COMPLETED
|
||||
}
|
||||
}
|
||||
|
||||
override fun getMangaUrl(manga: SManga) = if (preferences.openSource) {
|
||||
val id = manga.url.split("/").reversed()[1].toInt()
|
||||
entryExtraData.find { it.id == id }?.url ?: "$baseUrl${manga.url}"
|
||||
} else {
|
||||
"$baseUrl${manga.url}"
|
||||
}
|
||||
|
||||
// Chapter
|
||||
|
||||
override fun chapterListRequest(manga: SManga) =
|
||||
GET("$libraryUrl/${getPathFromUrl(manga.url)}", headers)
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val data = decodeBytes<Entry>(response.body)
|
||||
|
||||
return listOf(
|
||||
SChapter.create().apply {
|
||||
url = "/g/${data.id}/${data.key}"
|
||||
name = "Chapter"
|
||||
date_upload = data.publishedAt * 1000
|
||||
chapter_number = 1f
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun getChapterUrl(chapter: SChapter) = "$baseUrl/g/${getPathFromUrl(chapter.url)}"
|
||||
|
||||
// Page List
|
||||
|
||||
override fun pageListRequest(chapter: SChapter) =
|
||||
GET("$libraryUrl/${getPathFromUrl(chapter.url)}", headers)
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val data = decodeBytes<Entry>(response.body)
|
||||
val imageData = getImageData(data)
|
||||
|
||||
return data.data.mapIndexed { i, img ->
|
||||
Page(
|
||||
i,
|
||||
imageUrl = "$cdnUrl/${data.id}/${imageData.key}/${imageData.hash}/b/${img.name}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getImageData(entry: Entry): ImageData {
|
||||
val keys = entryExtraData.find { it.id == entry.id }
|
||||
|
||||
if (keys != null) {
|
||||
return ImageData(keys.id, keys.key, keys.hash)
|
||||
}
|
||||
|
||||
try {
|
||||
val response =
|
||||
client.newCall(GET("$libraryUrl/${entry.id}/${entry.key}/data", headers)).execute()
|
||||
val body = response.body
|
||||
|
||||
return decodeBytes(body)
|
||||
} catch (_: IOException) {
|
||||
throw IOException("Complete a Captcha in the site to continue")
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageRequest(page: Page): Request {
|
||||
return GET(page.imageUrl!!.replace("/b/", "/${preferences.imageQuality}/"), headers)
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not used")
|
||||
|
||||
// Settings
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
val imageQualityPref = ListPreference(screen.context).apply {
|
||||
key = IMAGE_QUALITY_PREF
|
||||
title = "Image quality"
|
||||
entries = arrayOf("Original", "Resampled")
|
||||
entryValues = arrayOf("a", "b")
|
||||
setDefaultValue("b")
|
||||
summary = "%s"
|
||||
setEnabled(false)
|
||||
}
|
||||
|
||||
val openSourcePref = SwitchPreferenceCompat(screen.context).apply {
|
||||
key = OPEN_SOURCE_PREF
|
||||
title = "Open in FAKKU in WebView"
|
||||
summary =
|
||||
"Enable to open the search the book in FAKKU when opening the manga or chapter in WebView. If only one result exists, it will open that one."
|
||||
setDefaultValue(false)
|
||||
}
|
||||
|
||||
screen.addPreference(imageQualityPref)
|
||||
screen.addPreference(openSourcePref)
|
||||
}
|
||||
|
||||
override fun getFilterList() = FilterList(
|
||||
CategoryGroup(),
|
||||
SortFilter(),
|
||||
FavoritesFilter(),
|
||||
)
|
||||
|
||||
private class CategoryFilter(name: String) : Filter.CheckBox(name, false)
|
||||
|
||||
private class FavoritesFilter : Filter.CheckBox(
|
||||
"Show only my favorites",
|
||||
)
|
||||
|
||||
private class CategoryGroup : Filter.Group<CategoryFilter>(
|
||||
"Categories",
|
||||
listOf("Manga", "Doujinshi", "Illustration").map { CategoryFilter(it) },
|
||||
)
|
||||
|
||||
private class SortFilter : Filter.Sort(
|
||||
"Sort",
|
||||
arrayOf("Title", "Pages", "Date published", "Date uploaded", "Popularity"),
|
||||
Selection(2, false),
|
||||
)
|
||||
|
||||
private val SharedPreferences.imageQuality
|
||||
get() = getString(IMAGE_QUALITY_PREF, "b")!!
|
||||
|
||||
private val SharedPreferences.openSource
|
||||
get() = getBoolean(OPEN_SOURCE_PREF, false)
|
||||
|
||||
private fun apiInterceptor(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val requestUrl = request.url.toString()
|
||||
|
||||
return if (requestUrl.contains("/api/v1")) {
|
||||
val newRequestBuilder = request.newBuilder()
|
||||
|
||||
if (requestUrl.contains(Regex("/\\d+/\\S+"))) {
|
||||
newRequestBuilder.header(
|
||||
"Referer",
|
||||
requestUrl.replace(libraryUrl, "$baseUrl/g"),
|
||||
)
|
||||
} else if (requestUrl.contains("user/favorites")) {
|
||||
newRequestBuilder.header(
|
||||
"Referer",
|
||||
requestUrl.replace("$apiUrl/user/favorites", "$baseUrl/favorites"),
|
||||
)
|
||||
} else {
|
||||
newRequestBuilder.header("Referer", requestUrl.replace(libraryUrl, baseUrl))
|
||||
}
|
||||
|
||||
chain.proceed(newRequestBuilder.build())
|
||||
} else {
|
||||
chain.proceed(request)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLoggedIn() = client.cookieJar.loadForRequest(baseUrl.toHttpUrl()).any {
|
||||
it.name == "session"
|
||||
}
|
||||
|
||||
private val entryExtraData by lazy {
|
||||
client.newCall(GET(KEYS_JSON, headers)).execute()
|
||||
.use { json.decodeFromStream<List<ExtraData>>(it.body.byteStream()) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val IMAGE_QUALITY_PREF = "image_quality"
|
||||
private const val OPEN_SOURCE_PREF = "use_manga_source"
|
||||
private const val KEYS_JSON =
|
||||
"https://gist.githubusercontent.com/LetrixZ/2b559cc5829d1c221c701e02ecd81411/raw/keys.json"
|
||||
}
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
package eu.kanade.tachiyomi.extension.en.anchira
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ListEntry(
|
||||
val id: Int,
|
||||
val key: String,
|
||||
val title: String,
|
||||
val cover: Image,
|
||||
val tags: List<Tag> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Image(
|
||||
@SerialName("n") val name: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Tag(
|
||||
var name: String,
|
||||
var namespace: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LibraryResponse(
|
||||
val entries: List<ListEntry> = emptyList(),
|
||||
val total: Int,
|
||||
val page: Int,
|
||||
val limit: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Entry(
|
||||
val id: Int,
|
||||
val key: String,
|
||||
@SerialName("published_at") val publishedAt: Long,
|
||||
val title: String,
|
||||
@SerialName("thumb_index") val thumbnailIndex: Int,
|
||||
val data: List<Image>,
|
||||
val tags: List<Tag> = emptyList(),
|
||||
val url: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ImageData(
|
||||
val id: Int,
|
||||
val key: String,
|
||||
val hash: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ExtraData(
|
||||
val id: Int,
|
||||
val key: String,
|
||||
val hash: String,
|
||||
val url: String?,
|
||||
)
|
@ -0,0 +1,34 @@
|
||||
package eu.kanade.tachiyomi.extension.en.anchira
|
||||
|
||||
import android.util.Base64
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.ResponseBody
|
||||
import okio.ByteString.Companion.decodeBase64
|
||||
|
||||
object AnchiraHelper {
|
||||
const val KEY = "ZnVjayBuaWdnZXJzIGFuZCBmYWdnb3RzLCBhbmQgZGVhdGggdG8gYWxsIGpld3M="
|
||||
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun getPathFromUrl(url: String) = "${url.split("/").reversed()[1]}/${url.split("/").last()}"
|
||||
|
||||
inline fun <reified T> decodeBytes(body: ResponseBody): T {
|
||||
val encryptedText = body.string().decodeBase64()!!
|
||||
return json.decodeFromString(
|
||||
XXTEA.decryptToString(
|
||||
encryptedText.toByteArray(),
|
||||
key = Base64.decode(KEY, Base64.DEFAULT).decodeToString(),
|
||||
)!!,
|
||||
)
|
||||
}
|
||||
|
||||
fun prepareTags(tags: List<Tag>) = tags.map {
|
||||
if (it.namespace == null) {
|
||||
it.namespace = 6
|
||||
}
|
||||
it
|
||||
}.sortedBy { it.namespace }.map {
|
||||
return@map it.name.lowercase()
|
||||
}.joinToString(", ") { it }
|
||||
}
|
@ -0,0 +1,107 @@
|
||||
package eu.kanade.tachiyomi.extension.en.anchira
|
||||
|
||||
object XXTEA {
|
||||
|
||||
private const val DELTA = -0x61c88647
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE", "FunctionName")
|
||||
private inline fun MX(sum: Int, y: Int, z: Int, p: Int, e: Int, k: IntArray): Int {
|
||||
return (z.ushr(5) xor (y shl 2)) + (y.ushr(3) xor (z shl 4)) xor (sum xor y) + (k[p and 3 xor e] xor z)
|
||||
}
|
||||
|
||||
private fun decrypt(data: ByteArray, key: ByteArray): ByteArray =
|
||||
data.takeIf { it.isNotEmpty() }
|
||||
?.let {
|
||||
decrypt(data.toIntArray(false), key.fixKey().toIntArray(false))
|
||||
.toByteArray(true)
|
||||
} ?: data
|
||||
|
||||
fun decrypt(data: ByteArray, key: String): ByteArray? =
|
||||
kotlin.runCatching { decrypt(data, key.toByteArray(Charsets.UTF_8)) }.getOrNull()
|
||||
|
||||
fun decryptToString(data: ByteArray, key: String): String? =
|
||||
kotlin.runCatching { decrypt(data, key)?.toString(Charsets.UTF_8) }.getOrNull()
|
||||
|
||||
private fun decrypt(v: IntArray, k: IntArray): IntArray {
|
||||
val n = v.size - 1
|
||||
|
||||
if (n < 1) {
|
||||
return v
|
||||
}
|
||||
var p: Int
|
||||
val q = 6 + 52 / (n + 1)
|
||||
var z: Int
|
||||
var y = v[0]
|
||||
var sum = q * DELTA
|
||||
var e: Int
|
||||
|
||||
while (sum != 0) {
|
||||
e = sum.ushr(2) and 3
|
||||
p = n
|
||||
while (p > 0) {
|
||||
z = v[p - 1]
|
||||
v[p] -= MX(sum, y, z, p, e, k)
|
||||
y = v[p]
|
||||
p--
|
||||
}
|
||||
z = v[n]
|
||||
v[0] -= MX(sum, y, z, p, e, k)
|
||||
y = v[0]
|
||||
sum -= DELTA
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
private fun ByteArray.fixKey(): ByteArray {
|
||||
if (size == 16) return this
|
||||
val fixedKey = ByteArray(16)
|
||||
|
||||
if (size < 16) {
|
||||
copyInto(fixedKey)
|
||||
} else {
|
||||
copyInto(fixedKey, endIndex = 16)
|
||||
}
|
||||
return fixedKey
|
||||
}
|
||||
|
||||
private fun ByteArray.toIntArray(includeLength: Boolean): IntArray {
|
||||
var n = if (size and 3 == 0) {
|
||||
size.ushr(2)
|
||||
} else {
|
||||
size.ushr(2) + 1
|
||||
}
|
||||
val result: IntArray
|
||||
|
||||
if (includeLength) {
|
||||
result = IntArray(n + 1)
|
||||
result[n] = size
|
||||
} else {
|
||||
result = IntArray(n)
|
||||
}
|
||||
n = size
|
||||
for (i in 0 until n) {
|
||||
result[i.ushr(2)] =
|
||||
result[i.ushr(2)] or (0x000000ff and this[i].toInt() shl (i and 3 shl 3))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun IntArray.toByteArray(includeLength: Boolean): ByteArray? {
|
||||
var n = size shl 2
|
||||
|
||||
if (includeLength) {
|
||||
val m = this[size - 1]
|
||||
n -= 4
|
||||
if (m < n - 3 || m > n) {
|
||||
return null
|
||||
}
|
||||
n = m
|
||||
}
|
||||
val result = ByteArray(n)
|
||||
|
||||
for (i in 0 until n) {
|
||||
result[i] = this[i.ushr(2)].ushr(i and 3 shl 3).toByte()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user