init projects
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
/build
|
||||
/videobox
|
||||
/bin
|
||||
back.env
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM bellsoft/liberica-openjdk-alpine:21
|
||||
|
||||
WORKDIR /usr/local/app
|
||||
COPY ./build/libs/server-fat-1.0.jar ./server-fat-1.0.jar
|
||||
EXPOSE 8181
|
||||
CMD ["java", "-jar", "server-fat-1.0.jar"]
|
||||
@@ -0,0 +1,111 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.backend.app)
|
||||
alias(libs.plugins.ktor)
|
||||
alias(libs.plugins.sqldelight)
|
||||
alias(libs.plugins.serialization)
|
||||
alias(libs.plugins.convention.koin)
|
||||
}
|
||||
|
||||
sqldelight {
|
||||
databases {
|
||||
create("AppDatabase") {
|
||||
packageName.set("ru.shadowsparky.vbox.backend")
|
||||
dialect("app.cash.sqldelight:postgresql-dialect:2.0.2")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":apps:vbox:common"))
|
||||
implementation(project(":libs:backend-base"))
|
||||
implementation(project(":libs:base"))
|
||||
implementation(project(":libs:http-client"))
|
||||
|
||||
implementation(libs.ktor.server.core.jvm)
|
||||
implementation(libs.ktor.server.host.common.jvm)
|
||||
implementation(libs.ktor.server.status.pages.jvm)
|
||||
implementation(libs.ktor.server.content.negotiation.jvm)
|
||||
implementation(libs.ktor.serialization.kotlinx.json.jvm)
|
||||
implementation(libs.ktor.server.netty.jvm)
|
||||
implementation(libs.ktor.server.auth)
|
||||
implementation(libs.ktor.server.auth.jwt)
|
||||
implementation(libs.ktor.server.websockets.jvm)
|
||||
implementation(libs.ktor.server.cors)
|
||||
|
||||
implementation(libs.androidx.datastore.preferences.core)
|
||||
implementation(libs.kxml2)
|
||||
implementation(libs.jsoup)
|
||||
implementation(libs.jdbc.driver)
|
||||
implementation(libs.postgresql)
|
||||
implementation(libs.coroutines.extensions)
|
||||
|
||||
testImplementation(libs.koin.test)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.toVersion(libs.versions.proj.server.java.get())
|
||||
targetCompatibility = JavaVersion.toVersion(libs.versions.proj.server.java.get())
|
||||
}
|
||||
|
||||
group = "ru.shadowsparky.vbox"
|
||||
version = "1.0"
|
||||
|
||||
val mainClassName = "ru.shadowsparky.vbox.backend.MainKt"
|
||||
|
||||
application {
|
||||
mainClass.set(mainClassName)
|
||||
|
||||
val isDevelopment: Boolean = project.ext.has("development")
|
||||
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
|
||||
}
|
||||
|
||||
tasks {
|
||||
register("fatJar", Jar::class) {
|
||||
archiveBaseName = "server-fat"
|
||||
manifest {
|
||||
attributes["Implementation-Title"] = "Videobox Server"
|
||||
attributes["Implementation-Version"] = version
|
||||
attributes["Main-Class"] = mainClassName
|
||||
}
|
||||
from(configurations.runtimeClasspath.get().map { if (it.isDirectory) it else zipTree(it) })
|
||||
duplicatesStrategy = DuplicatesStrategy.WARN
|
||||
with(jar.get() as CopySpec)
|
||||
}
|
||||
|
||||
"build" {
|
||||
dependsOn("fatJar")
|
||||
}
|
||||
|
||||
register<Exec>("deployRemote") {
|
||||
dependsOn("fatJar")
|
||||
commandLine(
|
||||
getCommandLine(
|
||||
"docker context use remote",
|
||||
"docker build . -t videobox-server",
|
||||
"docker context use default"
|
||||
)
|
||||
)
|
||||
notCompatibleWithConfigurationCache("This task uses Exec which is not compatible with configuration cache")
|
||||
}
|
||||
|
||||
register<Exec>("deployLocal") {
|
||||
dependsOn("fatJar")
|
||||
commandLine(
|
||||
getCommandLine(
|
||||
"docker compose down",
|
||||
"docker compose up -d --build --force-recreate"
|
||||
)
|
||||
)
|
||||
notCompatibleWithConfigurationCache("This task uses Exec which is not compatible with configuration cache")
|
||||
}
|
||||
}
|
||||
private fun getCommandLine(vararg commands: String): List<String> {
|
||||
val osName = System.getProperty("os.name").lowercase()
|
||||
return if (osName.contains("win")) {
|
||||
listOf("cmd", "/c") + commands.joinToString(" && ")
|
||||
} else {
|
||||
listOf("sh", "-c") + commands.joinToString(" && ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: always
|
||||
env_file:
|
||||
- postgres.env
|
||||
ports:
|
||||
- "5432:5432"
|
||||
networks:
|
||||
- serv-network
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./src/main/sqldelight/ru/shadowsparky/vbox/server/init.sq:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
|
||||
videobox:
|
||||
container_name: videobox
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
env_file:
|
||||
- postgres.env
|
||||
- back.env
|
||||
ports:
|
||||
- 8181:8181
|
||||
restart: always
|
||||
volumes:
|
||||
- ./videobox/cache/:/usr/local/app/cache:rw
|
||||
networks:
|
||||
- serv-network
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
networks:
|
||||
serv-network:
|
||||
driver: bridge
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -0,0 +1,8 @@
|
||||
POSTGRES_DB=videobox
|
||||
POSTGRES_USER=login
|
||||
POSTGRES_PASSWORD=password
|
||||
|
||||
VIDEOBOX_SERVER_URI=postgres
|
||||
VIDEOBOX_SECRET=144b5446caea8d7d1a33cc87b0447f3be608f0b12bef3d130ec37264375df912468411e37b432299d7f037db81f099b4b3572d4898fa6cc154e6931d7d12f78f
|
||||
|
||||
ALLOW_REGISTRATION=1
|
||||
@@ -0,0 +1,9 @@
|
||||
# Videobox Server
|
||||
|
||||
## Deploy
|
||||
|
||||
### Remote
|
||||
|
||||
Сначала нужно добавить remote context
|
||||
`docker context create remote --docker "host=ssh://root@localhost"`.
|
||||
Далее публикация происходит таской gradle: `gradle :apps:vbox:backend:deployRemote`
|
||||
@@ -0,0 +1,35 @@
|
||||
package ru.shadowsparky.vbox.backend
|
||||
|
||||
import org.koin.core.annotation.ComponentScan
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
import ru.shadowsparky.backend.data.EnvFetcher
|
||||
import ru.shadowsparky.backend.domain.JwtInfo
|
||||
import ru.shadowsparky.vbox.backend.di.DbModule
|
||||
import ru.shadowsparky.vbox.backend.di.EventModule
|
||||
import ru.shadowsparky.vbox.backend.di.HttpModule
|
||||
|
||||
@Module
|
||||
class VboxModule {
|
||||
|
||||
@Single
|
||||
fun provideJwtInfo(envFetcher: EnvFetcher): JwtInfo {
|
||||
return JwtInfo(
|
||||
envFetcher.get("VIDEOBOX_SECRET", "secret"),
|
||||
"https://shadowsparky.ru/",
|
||||
"https://box.shadowsparky.ru/",
|
||||
"server side data storage"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Module(
|
||||
includes = [
|
||||
DbModule::class,
|
||||
HttpModule::class,
|
||||
EventModule::class,
|
||||
VboxModule::class
|
||||
]
|
||||
)
|
||||
@ComponentScan
|
||||
class BackendModule
|
||||
@@ -0,0 +1,27 @@
|
||||
package ru.shadowsparky.vbox.backend
|
||||
|
||||
import io.ktor.server.application.Application
|
||||
import org.koin.core.annotation.KoinApplication
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import org.koin.plugin.module.dsl.startKoin
|
||||
import ru.shadowsparky.vbox.backend.presentation.configureJwt
|
||||
import ru.shadowsparky.vbox.backend.presentation.configureRouting
|
||||
import ru.shadowsparky.vbox.backend.presentation.configureSerialization
|
||||
import ru.shadowsparky.vbox.backend.presentation.configureWebSocket
|
||||
|
||||
@KoinApplication
|
||||
object VBoxBackend
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
startKoin<VBoxBackend>()
|
||||
io.ktor.server.netty.EngineMain.main(args)
|
||||
}
|
||||
|
||||
fun Application.module() {
|
||||
val koin = object : KoinComponent {}
|
||||
configureSerialization()
|
||||
configureJwt(koin.get())
|
||||
configureWebSocket(koin.get())
|
||||
configureRouting(koin.get(), koin.get())
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package ru.shadowsparky.vbox.backend.data
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
|
||||
import java.sql.SQLException
|
||||
|
||||
class BackendRecentlyWatchedRepository(
|
||||
private val db: AppDatabase,
|
||||
private val userId: Long,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val remoteEventHandler: RemoteEventHandler
|
||||
) : RecentlyWatchedRepository {
|
||||
|
||||
override suspend fun remove(info: RecentlyWatchedInfo) =
|
||||
withContext(dispatcherProvider.io) {
|
||||
db.recentQueries.deleteRwByIds(info.movieId, info.episode, info.season, userId)
|
||||
notifyChanged(info.movieId)
|
||||
}
|
||||
|
||||
override suspend fun write(info: RecentlyWatchedInfo) =
|
||||
withContext(dispatcherProvider.io) {
|
||||
try {
|
||||
db.recentQueries.insertRecentlyWatched(
|
||||
userId,
|
||||
info.movieId,
|
||||
info.episode,
|
||||
info.season
|
||||
)
|
||||
notifyChanged(info.movieId)
|
||||
} catch (ignored: SQLException) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun toggleInfo(info: RecentlyWatchedInfo) =
|
||||
withContext(dispatcherProvider.io) {
|
||||
val hasInfo =
|
||||
db.recentQueries.selectByIds(info.movieId, info.episode, info.season, userId)
|
||||
.executeAsOneOrNull() != null
|
||||
if (hasInfo) {
|
||||
remove(info)
|
||||
} else {
|
||||
write(info)
|
||||
}
|
||||
}
|
||||
|
||||
override fun queryRecentlyWatched(
|
||||
movieId: Long,
|
||||
seasonId: Long?
|
||||
): Flow<List<RecentlyWatchedInfo>> = flow {
|
||||
val rsp = if (seasonId == null) {
|
||||
db.recentQueries.selectByMovieId(movieId, userId)
|
||||
.executeAsList()
|
||||
.map {
|
||||
RecentlyWatchedInfo(
|
||||
it.movie_id,
|
||||
it.episode,
|
||||
it.season
|
||||
)
|
||||
}
|
||||
} else {
|
||||
db.recentQueries.selectByMovieIdAndSeason(movieId, userId, seasonId)
|
||||
.executeAsList()
|
||||
.map {
|
||||
RecentlyWatchedInfo(
|
||||
it.movie_id,
|
||||
it.episode,
|
||||
seasonId
|
||||
)
|
||||
}
|
||||
}
|
||||
emit(rsp)
|
||||
}
|
||||
|
||||
override suspend fun getAllRecentlyWatched(): List<RecentlyWatchedInfo> {
|
||||
return withContext(dispatcherProvider.io) {
|
||||
db.recentQueries.selectAll(userId).executeAsList()
|
||||
.map {
|
||||
RecentlyWatchedInfo(
|
||||
it.movie_id,
|
||||
it.episode,
|
||||
it.season
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun notifyChanged(seasonId: Long) {
|
||||
remoteEventHandler.notify(
|
||||
RemoteEvent.OnRecent(
|
||||
System.currentTimeMillis(),
|
||||
userId,
|
||||
seasonId
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package ru.shadowsparky.vbox.backend.data
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||
|
||||
val eventLogger: Logger = LoggerFactory.getLogger("events")
|
||||
|
||||
@Factory
|
||||
class BackendRemoteEventHandler(
|
||||
private val json: Json,
|
||||
private val sessionCache: SessionCache
|
||||
) : RemoteEventHandler {
|
||||
|
||||
suspend fun put(userId: Long, socketSession: SessionCache.Writer) {
|
||||
sessionCache.put(userId, socketSession)
|
||||
}
|
||||
|
||||
suspend fun remove(userId: Long, socketSession: SessionCache.Writer) {
|
||||
sessionCache.remove(userId, socketSession)
|
||||
}
|
||||
|
||||
override suspend fun notify(eventInfo: RemoteEvent) {
|
||||
val json = json.encodeToString(eventInfo)
|
||||
val writers = sessionCache.get(eventInfo.userId)
|
||||
if (writers.isNullOrEmpty()) {
|
||||
eventLogger.info("unable to notify $eventInfo. sessions not found, cache $sessionCache")
|
||||
} else {
|
||||
writers.forEach {
|
||||
eventLogger.info("notify[$eventInfo]. session $it", RuntimeException("called"))
|
||||
try {
|
||||
it.writeText(json)
|
||||
} catch (_: Exception) {
|
||||
eventLogger.error("unable to notify $it. delete session")
|
||||
remove(eventInfo.userId, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package ru.shadowsparky.vbox.backend.data
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.shadowsparky.backend.data.Logger
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
import ru.shadowsparky.vbox.server.Saved_movie
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.model.SERIAL_FLAG
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||
import java.sql.SQLException
|
||||
|
||||
class BackendSavedMovieRepository(
|
||||
private val db: AppDatabase,
|
||||
private val userId: Long,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val logger: Logger,
|
||||
private val eventHandler: RemoteEventHandler
|
||||
) : SavedMovieRepository {
|
||||
|
||||
override fun getAll(): Flow<List<VideoDetails>> {
|
||||
val request = db.saved_movieQueries.selectByUserId(userId)
|
||||
return flow {
|
||||
emit(request.executeAsList().parse().reversed())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun List<Saved_movie>.parse(): List<VideoDetails> =
|
||||
withContext(dispatcherProvider.io) {
|
||||
mapNotNull {
|
||||
val movie = db.movieQueries.selectMovie(it.movie_id)
|
||||
.executeAsOneOrNull() ?: return@mapNotNull null
|
||||
VideoDetails(
|
||||
movie.movie_id,
|
||||
movie.poster_url,
|
||||
movie.description,
|
||||
movie.title,
|
||||
(movie.flags and SERIAL_FLAG) != 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun save(details: VideoDetails) {
|
||||
logger.debug(TAG, "save(${details.id}) ${details.title}")
|
||||
withContext(dispatcherProvider.io) {
|
||||
try {
|
||||
db.saved_movieQueries.addSavedMovie(
|
||||
details.id,
|
||||
userId
|
||||
)
|
||||
eventHandler.notify(RemoteEvent.OnSaved(userId, details.id))
|
||||
} catch (_: SQLException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun isSaved(id: Long): Flow<Boolean> {
|
||||
return flow {
|
||||
val request = db.saved_movieQueries.selectByUserIdAndMovieId(userId, id)
|
||||
emit(request.executeAsOneOrNull() != null)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun remove(id: Long) {
|
||||
logger.debug(TAG, "remove(${id})")
|
||||
withContext(dispatcherProvider.io) {
|
||||
db.saved_movieQueries.removeSavedMovie(userId, id)
|
||||
eventHandler.notify(RemoteEvent.OnSaved(userId, id))
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "BackendSavedMovieRepository"
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package ru.shadowsparky.vbox.backend.data
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||
import ru.shadowsparky.vbox.shared.domain.SearchRepository
|
||||
import java.sql.SQLException
|
||||
|
||||
class BackendSearchRepository(
|
||||
private val db: AppDatabase,
|
||||
private val userId: Long,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val eventHandler: RemoteEventHandler
|
||||
) : SearchRepository {
|
||||
|
||||
override fun search(query: String): Flow<List<String>> {
|
||||
return flow { emit(searchSingle(query)) }
|
||||
}
|
||||
|
||||
private suspend fun searchSingle(query: String): List<String> = withContext(Dispatchers.IO) {
|
||||
if (query.trim().isEmpty()) {
|
||||
db.searchQueries.selectAll(userId)
|
||||
} else {
|
||||
db.searchQueries.selectByQuery(query.addWilcard(), userId)
|
||||
}.executeAsList().map { it.query }
|
||||
}
|
||||
|
||||
private fun String.addWilcard(): String {
|
||||
return if (this.endsWith("%")) this else "$this%"
|
||||
}
|
||||
|
||||
override suspend fun addToSearch(query: String) {
|
||||
withContext(dispatcherProvider.io) {
|
||||
try {
|
||||
db.searchQueries.insertSearchInfo(query, userId)
|
||||
notifyChanged()
|
||||
} catch (_: SQLException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteFromSearch(query: String) {
|
||||
withContext(dispatcherProvider.io) {
|
||||
db.searchQueries.deleteSearchInfo(query, userId)
|
||||
notifyChanged()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
withContext(dispatcherProvider.io) {
|
||||
db.searchQueries.deleteAll(userId)
|
||||
notifyChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun notifyChanged() {
|
||||
eventHandler.notify(RemoteEvent.OnSearch(System.currentTimeMillis(), userId))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package ru.shadowsparky.vbox.backend.data
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koin.core.annotation.Factory
|
||||
import java.util.Collections
|
||||
|
||||
typealias SessionMap = MutableMap<Long, List<SessionCache.Writer>>
|
||||
|
||||
@Factory
|
||||
class SessionCache {
|
||||
private val sessionMap: SessionMap = Collections.synchronizedMap(hashMapOf())
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
suspend fun put(userId: Long, socketSession: Writer) {
|
||||
eventLogger.info("put[$userId]=$socketSession")
|
||||
mutex.withLock {
|
||||
sessionMap[userId] = (sessionMap[userId] ?: mutableListOf()) + listOf(socketSession)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun get(userId: Long): List<Writer>? {
|
||||
mutex.withLock {
|
||||
return sessionMap[userId]
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun remove(userId: Long, session: Writer) {
|
||||
eventLogger.info("remove[$userId]=$session")
|
||||
mutex.withLock {
|
||||
val sessionFromMap = sessionMap[userId]?.toMutableList()
|
||||
val item = sessionFromMap?.firstOrNull { it == session } ?: return
|
||||
sessionFromMap.remove(item)
|
||||
sessionMap[userId] = sessionFromMap
|
||||
}
|
||||
}
|
||||
|
||||
fun interface Writer {
|
||||
suspend fun writeText(text: String)
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package ru.shadowsparky.vbox.backend.data.auth
|
||||
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.backend.data.EnvFetcher
|
||||
import ru.shadowsparky.backend.data.JwtPreparer
|
||||
import ru.shadowsparky.backend.data.StringFetcher
|
||||
import ru.shadowsparky.backend.data.StringResource
|
||||
import ru.shadowsparky.backend.domain.LoginVerifier
|
||||
import ru.shadowsparky.domain.Log
|
||||
import ru.shadowsparky.http.domain.BadRequestException
|
||||
import ru.shadowsparky.http.domain.TokenInfo
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
import ru.shadowsparky.vbox.server.Refresh_tokens
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.ChangePasswordRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.TokenRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.model.LoginInfo
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.io.encoding.Base64
|
||||
|
||||
@Factory
|
||||
class AuthTokenRepositoryFactory(
|
||||
private val db: AppDatabase,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val jwtPreparer: JwtPreparer,
|
||||
private val loginVerifier: LoginVerifier,
|
||||
private val log: Log,
|
||||
private val stringFetcher: StringFetcher,
|
||||
private val envFetcher: EnvFetcher
|
||||
) {
|
||||
|
||||
fun create(userId: Long = -1): AuthTokenRepository {
|
||||
return BackendAuthTokenRepository(
|
||||
db,
|
||||
dispatcherProvider,
|
||||
jwtPreparer,
|
||||
loginVerifier,
|
||||
userId,
|
||||
log,
|
||||
stringFetcher,
|
||||
envFetcher
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class BackendAuthTokenRepository(
|
||||
private val db: AppDatabase,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val jwtPreparer: JwtPreparer,
|
||||
private val loginVerifier: LoginVerifier,
|
||||
private val userId: Long,
|
||||
private val log: Log,
|
||||
private val stringFetcher: StringFetcher,
|
||||
private val envFetcher: EnvFetcher
|
||||
) : AuthTokenRepository {
|
||||
private val random = SecureRandom()
|
||||
|
||||
override suspend fun register(loginInfo: LoginInfo): TokenInfo {
|
||||
return if (envFetcher.get("ALLOW_REGISTRATION", "0") == "1") {
|
||||
registerInternal(loginInfo)
|
||||
} else {
|
||||
throw BadRequestException(stringFetcher.get(StringResource.REG_BLOCKED))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun registerInternal(loginInfo: LoginInfo): TokenInfo {
|
||||
return withContext(dispatcherProvider.io) {
|
||||
val user = db.usersQueries.selectUserByLogin(loginInfo.login).executeAsOneOrNull()
|
||||
if (user != null) throw BadRequestException(stringFetcher.get(StringResource.ALREADY_REGISTERED))
|
||||
db.usersQueries.addUser(
|
||||
loginInfo.login, loginInfo.passwordHash, System.currentTimeMillis(), null
|
||||
)
|
||||
TokenInfo("")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun login(loginInfo: LoginInfo): TokenInfo =
|
||||
withContext(dispatcherProvider.io) {
|
||||
val userInfo = db.usersQueries.selectUserByLogin(loginInfo.login).executeAsOneOrNull()
|
||||
?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER))
|
||||
if (loginInfo.passwordHash != userInfo.password_hash) {
|
||||
throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER))
|
||||
}
|
||||
create(loginInfo.login)
|
||||
}
|
||||
|
||||
override suspend fun update(refresh: TokenRequest): TokenInfo {
|
||||
val info = db.refresh_tokensQueries.selectByHash(refresh.token.toHash())
|
||||
.executeAsOneOrNull()
|
||||
?: throw BadRequestException("Token not found")
|
||||
val userInfo = db.usersQueries.selectUserByUserId(info.user_id).executeAsOneOrNull()
|
||||
?: throw BadRequestException("User not found")
|
||||
val newTokens = create(userInfo.login)
|
||||
revokeInternal(refresh)
|
||||
return newTokens
|
||||
}
|
||||
|
||||
override suspend fun revoke(tokenRequest: TokenRequest) {
|
||||
revokeInternal(tokenRequest)
|
||||
}
|
||||
|
||||
private suspend fun revokeInternal(request: TokenRequest) {
|
||||
log.d("BackendAuthTokenRepository", "revoke ${request.token.toHash()}")
|
||||
val info = getTokenAndCheck(request)
|
||||
db.refresh_tokensQueries.revokeToken(info.token_id).await()
|
||||
}
|
||||
|
||||
private suspend fun getTokenAndCheck(tokenRequest: TokenRequest): Refresh_tokens =
|
||||
withContext(dispatcherProvider.io) {
|
||||
val info = db.refresh_tokensQueries.selectByHash(tokenRequest.token.toHash())
|
||||
.executeAsOneOrNull()
|
||||
?: throw BadRequestException("Token not found")
|
||||
if (info.revoked) throw BadRequestException("Token already revoked")
|
||||
if (info.expires_at < System.currentTimeMillis()) {
|
||||
throw BadRequestException(
|
||||
"Refresh token expired. " +
|
||||
"Expires at ${info.expires_at} < ${System.currentTimeMillis()}"
|
||||
)
|
||||
}
|
||||
info
|
||||
}
|
||||
|
||||
private suspend fun create(login: String): TokenInfo = withContext(dispatcherProvider.io) {
|
||||
loginVerifier.verify(login)
|
||||
val userInfo = db.usersQueries.selectUserByLogin(login).executeAsOneOrNull()
|
||||
?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER))
|
||||
val rsp = jwtPreparer.prepare(login, userInfo.user_id)
|
||||
val refresh = ByteArray(32)
|
||||
random.nextBytes(refresh)
|
||||
val refreshStr = refresh.toHexString()
|
||||
db.refresh_tokensQueries.insertToken(
|
||||
userInfo.user_id,
|
||||
refreshStr.toHash(),
|
||||
System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30)
|
||||
).executeAsOneOrNull()
|
||||
log.d(
|
||||
"BackendAuthTokenRepository",
|
||||
"${refreshStr.toHash()} created for user ${userInfo.user_id}"
|
||||
)
|
||||
TokenInfo(rsp, refreshStr)
|
||||
}
|
||||
|
||||
override suspend fun changePassword(request: ChangePasswordRequest): Unit =
|
||||
withContext(dispatcherProvider.io) {
|
||||
val user = db.usersQueries.selectUserByUserId(userId).executeAsOneOrNull()
|
||||
?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER))
|
||||
if (user.password_hash != request.oldPasswordHash) {
|
||||
throw BadRequestException(stringFetcher.get(StringResource.INVALID_PASSWORD))
|
||||
} else if (user.password_hash == request.newPasswordHash) {
|
||||
throw BadRequestException(stringFetcher.get(StringResource.NO_CHANGES_PASS))
|
||||
}
|
||||
db.usersQueries.updatePassword(request.newPasswordHash, userId).await()
|
||||
}
|
||||
|
||||
private fun String.toHash(): String {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
md.update(SALT.toByteArray())
|
||||
val result = md.digest(this.toByteArray())
|
||||
return Base64.encode(result)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SALT = "G0yCPoy7gCj1a5OxPaeYLeJm69NvPQ50HbV0ZVYVqahUPdKun4MKgg86u9HbWq2e"
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package ru.shadowsparky.vbox.backend.data.auth
|
||||
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.backend.data.StringFetcher
|
||||
import ru.shadowsparky.backend.data.StringResource
|
||||
import ru.shadowsparky.backend.domain.LoginVerifier
|
||||
import ru.shadowsparky.backend.domain.VerifyTokenException
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
import ru.shadowsparky.vbox.shared.domain.FLAG_USER_BLOCKED
|
||||
|
||||
@Factory
|
||||
class BackendLoginVerifier(
|
||||
private val db: AppDatabase,
|
||||
private val stringFetcher: StringFetcher
|
||||
) : LoginVerifier {
|
||||
override suspend fun verify(login: String) {
|
||||
val user = db.usersQueries.selectUserByLogin(login)
|
||||
.executeAsOneOrNull()
|
||||
?: throw VerifyTokenException(stringFetcher.get(StringResource.UNKNOWN_USER))
|
||||
user.flags?.let {
|
||||
if ((it and FLAG_USER_BLOCKED) != 0) {
|
||||
throw VerifyTokenException(stringFetcher.get(StringResource.USER_BLOCKED))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package ru.shadowsparky.vbox.backend.data.http
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.url
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.backend.data.EnvFetcher
|
||||
import ru.shadowsparky.backend.data.StringFetcher
|
||||
import ru.shadowsparky.backend.data.StringResource
|
||||
import ru.shadowsparky.http.domain.NotFoundException
|
||||
import ru.shadowsparky.vbox.shared.domain.VideoApi
|
||||
import ru.shadowsparky.vbox.shared.domain.model.Season
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoItem
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideosResponse
|
||||
|
||||
private const val FALLBACK_SEASON_ID = -1L
|
||||
private const val SEASON_FALLBACK_NAME = "Сезон 1"
|
||||
|
||||
@Factory(binds = [ExternalBackendApi::class])
|
||||
class ExternalBackendApi(
|
||||
private val client: HttpClient,
|
||||
private val mapper: ExternalBackendMapper,
|
||||
private val cache: ExternalCache,
|
||||
private val envFetcher: EnvFetcher,
|
||||
private val stringFetcher: StringFetcher
|
||||
) : VideoApi {
|
||||
private val notFoundException =
|
||||
NotFoundException(stringFetcher.get(StringResource.VIDEO_NOT_FOUND))
|
||||
private val endpoint = envFetcher.get(BACKEND_HOST)
|
||||
|
||||
override suspend fun fetchNewVideos(query: String?): VideosResponse {
|
||||
return coroutineScope {
|
||||
val movies = async { fetchFavoritesInternal("movie", query) }
|
||||
val series = async { fetchFavoritesInternal("series", query) }
|
||||
val anime = async { fetchFavoritesInternal("anime", query) }
|
||||
val result = (series.await() + movies.await() + anime.await())
|
||||
result.map {
|
||||
async { cache.put(it) }
|
||||
}.awaitAll()
|
||||
return@coroutineScope VideosResponse(false, result, 1)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchFavoritesInternal(request: String, search: String?): List<VideoItem> {
|
||||
val rsp = client.get {
|
||||
url("https", endpoint, path = "${envFetcher.get(FAVORITES_PREFIX)}$request")
|
||||
if (search != null) {
|
||||
parameter("search", search)
|
||||
}
|
||||
}
|
||||
val rspText = rsp.bodyAsText()
|
||||
return mapper.movieRspToItems(rspText, request)
|
||||
}
|
||||
|
||||
override suspend fun fetchDetails(id: Long): VideoDetails {
|
||||
val info = cache.get(id) ?: throw notFoundException
|
||||
return VideoDetails(
|
||||
poster = mapper.mapPoster(info.poster),
|
||||
id = info.id,
|
||||
title = info.title,
|
||||
desc = info.description ?: "",
|
||||
isSerial = info.isSerial
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun fetchVideoLinks(
|
||||
id: Long,
|
||||
seasonId: Long?
|
||||
): VideoLinksResponse {
|
||||
val isSerial = cache.get(id)?.isSerial ?: throw notFoundException
|
||||
return if (isSerial) {
|
||||
val seasons = mutableListOf<Season>()
|
||||
if (seasonId == null) {
|
||||
getSerialSeasonIds(id).forEach { seasons.add(Season(emptyList(), it.id, it.title)) }
|
||||
if (seasons.isEmpty()) {
|
||||
seasons.add(Season(emptyList(), FALLBACK_SEASON_ID, SEASON_FALLBACK_NAME))
|
||||
}
|
||||
} else {
|
||||
if (seasonId == FALLBACK_SEASON_ID) {
|
||||
seasons.add(getSerialSeasonFallback(id))
|
||||
} else {
|
||||
val entry = getSerialSeasonIds(id).firstOrNull { it.id == seasonId }
|
||||
?: throw notFoundException
|
||||
seasons.add(getSeason(entry))
|
||||
}
|
||||
}
|
||||
if (seasons.isEmpty()) throw notFoundException
|
||||
VideoLinksResponse(seasons = seasons)
|
||||
} else {
|
||||
val rsp = client.get {
|
||||
url("https", endpoint, path = envFetcher.get(LINKS_PATH))
|
||||
parameter("id", id)
|
||||
parameter("movie", "")
|
||||
}.bodyAsText()
|
||||
VideoLinksResponse(files = mapper.parseMovie(rsp) ?: throw notFoundException)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getSerialSeasonIds(id: Long): List<ExternalBackendMapper.SeasonEntry> {
|
||||
val rsp = client.get {
|
||||
url("https", endpoint, path = envFetcher.get(SEASON_IDS_PATH))
|
||||
parameter("id", id)
|
||||
}
|
||||
val text = rsp.bodyAsText()
|
||||
return mapper.categoryRspToSeasonInfo(text)
|
||||
}
|
||||
|
||||
private suspend fun getSerialSeasonFallback(id: Long): Season {
|
||||
val rsp = client.get {
|
||||
url("https", endpoint, path = envFetcher.get(LINKS_PATH))
|
||||
parameter("id", id)
|
||||
}
|
||||
val text = rsp.bodyAsText()
|
||||
return mapper.parseSeason(
|
||||
text,
|
||||
ExternalBackendMapper.SeasonEntry(
|
||||
SEASON_FALLBACK_NAME,
|
||||
FALLBACK_SEASON_ID
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getSeason(entry: ExternalBackendMapper.SeasonEntry): Season {
|
||||
val rsp2 = client.get {
|
||||
url("https", endpoint, path = envFetcher.get(SEASON_PATH))
|
||||
parameter("id", entry.id)
|
||||
}.bodyAsText()
|
||||
return mapper.parseSeason(rsp2, entry)
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
package ru.shadowsparky.vbox.backend.data.http
|
||||
|
||||
import org.jsoup.Jsoup
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.backend.data.EnvFetcher
|
||||
import ru.shadowsparky.vbox.shared.domain.model.Episode
|
||||
import ru.shadowsparky.vbox.shared.domain.model.File
|
||||
import ru.shadowsparky.vbox.shared.domain.model.Season
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoItem
|
||||
import java.net.URI
|
||||
|
||||
@Factory
|
||||
class ExternalBackendMapper(
|
||||
private val envFetcher: EnvFetcher
|
||||
) {
|
||||
private val readerFactory: () -> XmlReader = { XmlReader() }
|
||||
|
||||
fun movieRspToItems(rsp: String, rootTag: String): List<VideoItem> {
|
||||
val reader = readerFactory().apply { setInput(rsp) }
|
||||
return readRoot(reader, rootTag).toItems()
|
||||
}
|
||||
|
||||
fun categoryRspToSeasonInfo(rsp: String): List<SeasonEntry> {
|
||||
val reader = readerFactory().apply { setInput(rsp) }
|
||||
val movies = readRoot(reader, "category").unit
|
||||
return movies.map { SeasonEntry(it.title!!, it.id.toLong()) }
|
||||
}
|
||||
|
||||
fun parseSeason(rsp: String, seasonEntry: SeasonEntry): Season {
|
||||
val reader = readerFactory().apply { setInput(rsp) }
|
||||
val videos = readRoot(reader, "video").unit
|
||||
val episodes = mutableListOf<Episode>()
|
||||
videos.forEachIndexed { index, hlamerUnit ->
|
||||
episodes.add(hlamerUnit.toEpisode(index + 1L))
|
||||
}
|
||||
return Season(episodes, seasonEntry.id, seasonEntry.title.parseHtml())
|
||||
}
|
||||
|
||||
fun parseMovie(rsp: String): List<File>? {
|
||||
val reader = readerFactory().apply { setInput(rsp) }
|
||||
val files = readRoot(reader, "video").unit.firstOrNull() ?: return null
|
||||
return listOf(File(false, -1, files.file?.loadMp4FromSparky() ?: return null))
|
||||
}
|
||||
|
||||
private fun ExternalUnit.toEpisode(epNumber: Long): Episode {
|
||||
val hasTitle = title?.trim()?.isNotEmpty() == true
|
||||
val splitTitle = title?.split(""")
|
||||
?.lastOrNull { it.isNotEmpty() }
|
||||
?: title?.removeSurrounding(""")
|
||||
val episodeText = "Серия"
|
||||
return Episode(
|
||||
epNumber,
|
||||
listOf(File(false, -1, file!!.loadMp4FromSparky())),
|
||||
if (hasTitle) "$epNumber. $splitTitle" else "$episodeText $epNumber"
|
||||
)
|
||||
}
|
||||
|
||||
private fun readRoot(reader: XmlReader, rootTag: String): Movie {
|
||||
reader.require(XmlReader.Tag.START, rootTag)
|
||||
val entries = mutableListOf<ExternalUnit>()
|
||||
while (reader.next != XmlReader.Tag.END) {
|
||||
if (reader.eventType != XmlReader.Tag.START) {
|
||||
continue
|
||||
}
|
||||
if (reader.name == "unit") {
|
||||
val entry = readUnitEntry(reader)
|
||||
entries.add(entry)
|
||||
}
|
||||
}
|
||||
return Movie(entries)
|
||||
}
|
||||
|
||||
private fun readUnitEntry(reader: XmlReader): ExternalUnit {
|
||||
reader.require(XmlReader.Tag.START, "unit")
|
||||
val id = reader.getAttributeValue("id")
|
||||
var title: String? = null
|
||||
var description: String? = null
|
||||
var thumb: String? = null
|
||||
var section: String? = null
|
||||
var file: String? = null
|
||||
var image: String? = null
|
||||
while (reader.next != XmlReader.Tag.END) {
|
||||
if (reader.eventType != XmlReader.Tag.START) {
|
||||
continue
|
||||
}
|
||||
when (reader.name) {
|
||||
"title" -> title = readText(reader)
|
||||
"description" -> description = readText(reader)
|
||||
"thumb" -> thumb = readText(reader)
|
||||
"section" -> section = readText(reader)
|
||||
"file" -> file = readText(reader)
|
||||
"image" -> image = readText(reader)
|
||||
else -> readText(reader) // skip
|
||||
}
|
||||
}
|
||||
return ExternalUnit(id, title, description, thumb, section, file, image)
|
||||
}
|
||||
|
||||
private fun readText(reader: XmlReader): String {
|
||||
var result = ""
|
||||
if (reader.next == XmlReader.Tag.TEXT) {
|
||||
result = reader.text
|
||||
reader.nextTag()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun Movie.toItems(): List<VideoItem> {
|
||||
return unit.mapNotNull { it.toVideoItem() }
|
||||
}
|
||||
|
||||
private fun ExternalUnit.toVideoItem(): VideoItem? {
|
||||
val poster = thumb?.loadPosterFromSparky() ?: return null
|
||||
if (poster.endsWith("/_180.jpg")) {
|
||||
return null
|
||||
}
|
||||
val isMovie = section == "movie"
|
||||
return VideoItem(
|
||||
id = id.toLong(),
|
||||
title = title?.parseHtml() ?: return null,
|
||||
poster = poster,
|
||||
description = description?.parseHtml(),
|
||||
isSerial = !isMovie
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.loadMp4FromSparky(): String {
|
||||
return mapExternalToBox(this) ?: this
|
||||
}
|
||||
|
||||
private val hostRegex = Regex("""^m(\d+)\.${envFetcher.get(REGEX_HOST)}\.ru$""")
|
||||
|
||||
private fun mapExternalToBox(sourceUrl: String): String? {
|
||||
val uri = try {
|
||||
URI(sourceUrl)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
val host = uri.host ?: return null
|
||||
|
||||
val m = hostRegex.matchEntire(host) ?: return null
|
||||
val idx = m.groupValues[1]
|
||||
|
||||
val rawPath = uri.rawPath ?: return null
|
||||
if (!rawPath.startsWith("/video")) return null
|
||||
|
||||
val rest = rawPath.removePrefix("/video")
|
||||
val newPath = "/video/m$idx$rest"
|
||||
|
||||
return URI(
|
||||
"https",
|
||||
null,
|
||||
"box.shadowsparky.ru",
|
||||
uri.port,
|
||||
newPath,
|
||||
uri.rawQuery,
|
||||
uri.rawFragment
|
||||
).toString()
|
||||
}
|
||||
|
||||
fun mapPoster(poster: String): String {
|
||||
return poster.loadPosterFromSparky()
|
||||
}
|
||||
|
||||
private fun String.loadPosterFromSparky(): String {
|
||||
val imageEndpoint = envFetcher.get(IMAGE_ENDPOINT)
|
||||
if (startsWith(imageEndpoint)) {
|
||||
return replace(imageEndpoint, SPARKY_IMAGE_ENDPOINT)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
data class SeasonEntry(
|
||||
val title: String,
|
||||
val id: Long
|
||||
)
|
||||
|
||||
private data class Movie(
|
||||
val unit: List<ExternalUnit>
|
||||
)
|
||||
|
||||
private data class ExternalUnit(
|
||||
val id: String,
|
||||
val title: String?,
|
||||
val description: String?,
|
||||
val thumb: String?,
|
||||
val section: String?,
|
||||
val file: String?,
|
||||
val image: String?
|
||||
)
|
||||
|
||||
private fun String.parseHtml(): String {
|
||||
return Jsoup.parse(this@parseHtml).wholeText()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SPARKY_IMAGE_ENDPOINT = "https://box.shadowsparky.ru/images/"
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package ru.shadowsparky.vbox.backend.data.http
|
||||
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.annotation.Single
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.model.MOVIE_FLAG
|
||||
import ru.shadowsparky.vbox.shared.domain.model.SERIAL_FLAG
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoItem
|
||||
|
||||
@Single
|
||||
class ExternalCache(
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val db: AppDatabase
|
||||
) {
|
||||
|
||||
suspend fun put(item: VideoItem) {
|
||||
insertIfNeeded(item)
|
||||
}
|
||||
|
||||
suspend fun get(id: Long): VideoItem? = queryOrNull(id)
|
||||
|
||||
private suspend fun queryOrNull(id: Long): VideoItem? {
|
||||
return withContext(dispatcherProvider.io) {
|
||||
val oldInfo = db.movieQueries.selectMovie(id)
|
||||
.executeAsOneOrNull()
|
||||
?: return@withContext null
|
||||
VideoItem(
|
||||
oldInfo.movie_id,
|
||||
oldInfo.poster_url,
|
||||
oldInfo.title,
|
||||
oldInfo.description,
|
||||
oldInfo.flags == SERIAL_FLAG
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun insertIfNeeded(new: VideoItem) = withContext(dispatcherProvider.io) {
|
||||
val old = queryOrNull(new.id)
|
||||
if (old == null) {
|
||||
db.movieQueries.insertMovie(
|
||||
new.id,
|
||||
new.poster,
|
||||
new.description ?: "",
|
||||
new.title,
|
||||
new.serializeFlags()
|
||||
)
|
||||
} else if (old != new) {
|
||||
db.movieQueries.updateMovie(
|
||||
new.poster,
|
||||
new.description ?: "",
|
||||
new.title,
|
||||
new.serializeFlags(),
|
||||
new.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun VideoItem.serializeFlags(): Int {
|
||||
return if (isSerial) {
|
||||
SERIAL_FLAG
|
||||
} else {
|
||||
MOVIE_FLAG
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package ru.shadowsparky.vbox.backend.data.http
|
||||
|
||||
const val REGEX_HOST = "EXTERNAL_HOST"
|
||||
const val IMAGE_ENDPOINT = "EXTERNAL_IMAGE_ENDPOINT"
|
||||
const val CACHE_DIR = "EXTERNAL_CACHE_DIR"
|
||||
const val BACKEND_HOST = "EXTERNAL_BACKEND_HOST"
|
||||
const val FAVORITES_PREFIX = "EXTERNAL_FAVORITES_PREFIX"
|
||||
const val LINKS_PATH = "EXTERNAL_LINKS_PATH"
|
||||
const val SEASON_IDS_PATH = "EXTERNAL_SEASON_IDS_PATH"
|
||||
const val SEASON_PATH = "EXTERNAL_SEASON_PATH"
|
||||
@@ -0,0 +1,61 @@
|
||||
package ru.shadowsparky.vbox.backend.data.http
|
||||
|
||||
import org.kxml2.io.KXmlParser
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.StringReader
|
||||
|
||||
class XmlReader {
|
||||
private var parser: XmlPullParser? = null
|
||||
|
||||
val name: String
|
||||
get() = parser!!.name
|
||||
val eventType: Tag
|
||||
get() = parser!!.eventType.fromType()
|
||||
val next: Tag
|
||||
get() = parser!!.next().fromType()
|
||||
|
||||
fun setInput(raw: String) {
|
||||
parser = KXmlParser().apply {
|
||||
setInput(StringReader(raw))
|
||||
nextTag()
|
||||
}
|
||||
}
|
||||
|
||||
fun require(
|
||||
tag: Tag,
|
||||
name: String
|
||||
) {
|
||||
parser?.require(tag.toType(), null, name)
|
||||
}
|
||||
|
||||
fun Tag.toType(): Int {
|
||||
return when (this) {
|
||||
Tag.START -> XmlPullParser.START_TAG
|
||||
Tag.END -> XmlPullParser.END_TAG
|
||||
Tag.TEXT -> XmlPullParser.TEXT
|
||||
}
|
||||
}
|
||||
|
||||
fun Int.fromType(): Tag {
|
||||
return when (this) {
|
||||
XmlPullParser.START_TAG -> Tag.START
|
||||
XmlPullParser.END_TAG -> Tag.END
|
||||
else -> Tag.TEXT
|
||||
}
|
||||
}
|
||||
|
||||
fun getAttributeValue(name: String): String {
|
||||
return parser!!.getAttributeValue(null, name)
|
||||
}
|
||||
|
||||
fun nextTag() {
|
||||
parser!!.nextTag()
|
||||
}
|
||||
|
||||
enum class Tag {
|
||||
START, END, TEXT
|
||||
}
|
||||
|
||||
val text: String
|
||||
get() = parser!!.text
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package ru.shadowsparky.vbox.backend.data.tags
|
||||
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import app.cash.sqldelight.coroutines.mapToList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
|
||||
|
||||
@Factory
|
||||
class MovieTagRepositoryFactory(
|
||||
private val appDatabase: AppDatabase,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
@Named(EventType.MOVIE_TAG) private val movieEventHandler: RemoteEventHandler
|
||||
) {
|
||||
fun create(userId: Long): MovieTagRepository {
|
||||
return BackendMovieTagRepository(
|
||||
appDatabase,
|
||||
dispatcherProvider,
|
||||
userId,
|
||||
movieEventHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class BackendMovieTagRepository(
|
||||
private val db: AppDatabase,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val userId: Long,
|
||||
private val movieEventHandler: RemoteEventHandler
|
||||
) : MovieTagRepository {
|
||||
private val dbQueries get() = db.tagsQueries
|
||||
|
||||
override fun query(movieId: Long): Flow<List<TagInfo>> = dbQueries
|
||||
.getTagsForMovie(movieId, userId)
|
||||
.asFlow()
|
||||
.mapToList(dispatcherProvider.io)
|
||||
.map { list ->
|
||||
list.map { row -> TagInfo(id = row.id, tag = row.tag) }
|
||||
}
|
||||
|
||||
override fun queryMovies(tagId: Long): Flow<Set<Long>> = dbQueries
|
||||
.getMoviesByTagId(tagId, userId)
|
||||
.asFlow()
|
||||
.mapToList(dispatcherProvider.io)
|
||||
.map { list -> list.toSet() }
|
||||
|
||||
override suspend fun link(movieId: Long, tagId: Long) {
|
||||
dbQueries.linkMovieTag(movieId, tagId, userId).await()
|
||||
movieEventHandler.notify(RemoteEvent.OnMovieTag(userId, movieId))
|
||||
}
|
||||
|
||||
override suspend fun unlink(movieId: Long, tagId: Long) {
|
||||
dbQueries.unlinkMovieTag(movieId, tagId, userId).await()
|
||||
movieEventHandler.notify(RemoteEvent.OnMovieTag(userId, movieId))
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package ru.shadowsparky.vbox.backend.data.tags
|
||||
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import app.cash.sqldelight.coroutines.mapToList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
|
||||
|
||||
@Factory
|
||||
class UserTagRepositoryFactory(
|
||||
private val db: AppDatabase,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
@Named(EventType.USER_TAG) private val userTagEventHandler: RemoteEventHandler
|
||||
) {
|
||||
fun create(userId: Long): UserTagRepository {
|
||||
return BackendUserTagRepository(
|
||||
userId,
|
||||
db,
|
||||
dispatcherProvider,
|
||||
userTagEventHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class BackendUserTagRepository(
|
||||
private val userId: Long,
|
||||
private val db: AppDatabase,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val userTagEventHandler: RemoteEventHandler
|
||||
) : UserTagRepository {
|
||||
private val queries get() = db.tagsQueries
|
||||
|
||||
override val userTags: Flow<List<TagInfo>> by lazy {
|
||||
queries.getUserTagsByUserId(user_id = userId)
|
||||
.asFlow()
|
||||
.mapToList(dispatcherProvider.io)
|
||||
.map { list ->
|
||||
list.map { row -> TagInfo(id = row.id, tag = row.tag) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun add(tag: String) {
|
||||
queries.insertUserTag(userId, tag).await()
|
||||
userTagEventHandler.notify(RemoteEvent.OnUserTag(userId))
|
||||
}
|
||||
|
||||
override suspend fun edit(id: Long, newTag: String) {
|
||||
checkOwner(id)
|
||||
queries.updateTagText(newTag, id).await()
|
||||
userTagEventHandler.notify(RemoteEvent.OnUserTag(userId))
|
||||
}
|
||||
|
||||
override suspend fun delete(id: Long) {
|
||||
checkOwner(id)
|
||||
queries.deleteTagById(id).await()
|
||||
userTagEventHandler.notify(RemoteEvent.OnUserTag(userId))
|
||||
}
|
||||
|
||||
private suspend fun checkOwner(id: Long) = withContext(dispatcherProvider.io) {
|
||||
val ownerId = queries.getUserIdByTagId(id).executeAsOneOrNull()
|
||||
check(ownerId == userId) { "tag owner mismatch. owner: $ownerId, current user: $userId" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package ru.shadowsparky.vbox.backend.di
|
||||
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import app.cash.sqldelight.driver.jdbc.asJdbcDriver
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
import org.postgresql.ds.PGSimpleDataSource
|
||||
import ru.shadowsparky.backend.data.EnvFetcher
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
|
||||
@Module
|
||||
class DbModule {
|
||||
|
||||
@Factory
|
||||
fun provideSqlDriver(envFetcher: EnvFetcher): SqlDriver {
|
||||
return PGSimpleDataSource().apply {
|
||||
val db = envFetcher.get("POSTGRES_DB", "videobox")
|
||||
val uri = envFetcher.get("VIDEOBOX_SERVER_URI", "localhost")
|
||||
val login = envFetcher.get("POSTGRES_USER", "login")
|
||||
val pass = envFetcher.get("POSTGRES_PASSWORD", "password")
|
||||
|
||||
setUrl("jdbc:postgresql://$uri:5432/$db")
|
||||
user = login
|
||||
password = pass
|
||||
}.asJdbcDriver()
|
||||
}
|
||||
|
||||
@Single
|
||||
fun provideDb(driver: SqlDriver): AppDatabase {
|
||||
return AppDatabase.Companion.invoke(driver)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package ru.shadowsparky.vbox.backend.di
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import ru.shadowsparky.backend.data.TokenVerifier
|
||||
import ru.shadowsparky.backend.domain.JwtInfo
|
||||
import ru.shadowsparky.backend.domain.LoginVerifier
|
||||
import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler
|
||||
import ru.shadowsparky.vbox.backend.data.auth.AuthTokenRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.data.tags.MovieTagRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.data.tags.UserTagRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.di.factory.RecentlyWatchedRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.di.factory.SavedMovieRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.di.factory.SearchRepositoryFactory
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.VideoApi
|
||||
|
||||
@Single
|
||||
class RoutingEntryPoint(
|
||||
val videoApi: VideoApi,
|
||||
val searchFactory: SearchRepositoryFactory,
|
||||
val recentlyFactory: RecentlyWatchedRepositoryFactory,
|
||||
val savedMovieFactory: SavedMovieRepositoryFactory,
|
||||
val json: Json,
|
||||
val movieTagFactory: MovieTagRepositoryFactory,
|
||||
val userTagFactory: UserTagRepositoryFactory
|
||||
)
|
||||
|
||||
@Single
|
||||
class WebSocketEntryPoint(
|
||||
@Named(EventType.SEARCH) val searchEventHandler: BackendRemoteEventHandler,
|
||||
@Named(EventType.RECENT) val recentlyEventHandler: BackendRemoteEventHandler,
|
||||
@Named(EventType.SAVED) val savedEventHandler: BackendRemoteEventHandler,
|
||||
@Named(EventType.USER_TAG) val userTagEventHandler: BackendRemoteEventHandler,
|
||||
@Named(EventType.MOVIE_TAG) val movieTagEventHandler: BackendRemoteEventHandler,
|
||||
val tokenVerifier: TokenVerifier
|
||||
)
|
||||
|
||||
@Single
|
||||
class AuthEntryPoint(
|
||||
val loginVerifier: LoginVerifier,
|
||||
val authTokenRepositoryFactory: AuthTokenRepositoryFactory,
|
||||
val jwtInfo: JwtInfo,
|
||||
val tokenVerifier: TokenVerifier
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
package ru.shadowsparky.vbox.backend.di
|
||||
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||
|
||||
@Module
|
||||
class EventModule {
|
||||
|
||||
@Single(binds = [BackendRemoteEventHandler::class])
|
||||
@Named(EventType.SEARCH)
|
||||
fun provideSearch(impl: BackendRemoteEventHandler): RemoteEventHandler = impl
|
||||
|
||||
@Single(binds = [BackendRemoteEventHandler::class])
|
||||
@Named(EventType.RECENT)
|
||||
fun provideRecent(impl: BackendRemoteEventHandler): RemoteEventHandler = impl
|
||||
|
||||
@Single(binds = [BackendRemoteEventHandler::class])
|
||||
@Named(EventType.SAVED)
|
||||
fun provideSaved(impl: BackendRemoteEventHandler): RemoteEventHandler = impl
|
||||
|
||||
@Single(binds = [BackendRemoteEventHandler::class])
|
||||
@Named(EventType.USER_TAG)
|
||||
fun provideUserTag(impl: BackendRemoteEventHandler): RemoteEventHandler = impl
|
||||
|
||||
@Single(binds = [BackendRemoteEventHandler::class])
|
||||
@Named(EventType.MOVIE_TAG)
|
||||
fun provideMovieTag(impl: BackendRemoteEventHandler): RemoteEventHandler = impl
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package ru.shadowsparky.vbox.backend.di
|
||||
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Module
|
||||
import ru.shadowsparky.vbox.backend.data.http.ExternalBackendApi
|
||||
import ru.shadowsparky.vbox.shared.domain.VideoApi
|
||||
|
||||
@Module
|
||||
class HttpModule {
|
||||
|
||||
@Factory
|
||||
fun provideVideoApi(impl: ExternalBackendApi): VideoApi = impl
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package ru.shadowsparky.vbox.backend.di.factory
|
||||
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
import ru.shadowsparky.vbox.backend.data.BackendRecentlyWatchedRepository
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||
|
||||
@Factory
|
||||
class RecentlyWatchedRepositoryFactory(
|
||||
private val db: AppDatabase,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
@Named(EventType.RECENT) private val remoteEventHandler: RemoteEventHandler
|
||||
) {
|
||||
fun create(userId: Long): BackendRecentlyWatchedRepository {
|
||||
return BackendRecentlyWatchedRepository(db, userId, dispatcherProvider, remoteEventHandler)
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package ru.shadowsparky.vbox.backend.di.factory
|
||||
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.backend.data.Logger
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler
|
||||
import ru.shadowsparky.vbox.backend.data.BackendSavedMovieRepository
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
|
||||
|
||||
@Factory
|
||||
class SavedMovieRepositoryFactory(
|
||||
private val db: AppDatabase,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val logger: Logger,
|
||||
@Named(EventType.SAVED) private val eventHandler: BackendRemoteEventHandler
|
||||
) {
|
||||
fun create(userId: Long): SavedMovieRepository {
|
||||
return BackendSavedMovieRepository(db, userId, dispatcherProvider, logger, eventHandler)
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package ru.shadowsparky.vbox.backend.di.factory
|
||||
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
import ru.shadowsparky.vbox.backend.data.BackendSearchRepository
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||
import ru.shadowsparky.vbox.shared.domain.SearchRepository
|
||||
|
||||
@Factory
|
||||
class SearchRepositoryFactory(
|
||||
private val db: AppDatabase,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
@Named(EventType.SEARCH) private val eventHandler: RemoteEventHandler
|
||||
) {
|
||||
fun create(userId: Long): SearchRepository {
|
||||
return BackendSearchRepository(db, userId, dispatcherProvider, eventHandler)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package ru.shadowsparky.vbox.backend.presentation
|
||||
|
||||
import com.auth0.jwt.JWT
|
||||
import com.auth0.jwt.interfaces.DecodedJWT
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.auth.Authentication
|
||||
import io.ktor.server.auth.jwt.JWTPrincipal
|
||||
import io.ktor.server.auth.jwt.jwt
|
||||
import io.ktor.server.response.respond
|
||||
import ru.shadowsparky.backend.domain.INVALID_TOKEN_MSG
|
||||
import ru.shadowsparky.backend.domain.LOGIN_NAME
|
||||
import ru.shadowsparky.backend.domain.VerifyTokenException
|
||||
import ru.shadowsparky.vbox.backend.di.AuthEntryPoint
|
||||
import ru.shadowsparky.vbox.shared.domain.USER_ID_ARG
|
||||
import ru.shadowsparky.vbox.shared.domain.model.ServerExceptionInfo
|
||||
|
||||
const val AUTH_JWT_NAME = "auth-jwt"
|
||||
|
||||
fun Application.configureJwt(authEntryPoint: AuthEntryPoint) {
|
||||
install(Authentication) {
|
||||
jwt(AUTH_JWT_NAME) {
|
||||
val jwtInfo = authEntryPoint.jwtInfo
|
||||
realm = jwtInfo.realm
|
||||
verifier(authEntryPoint.tokenVerifier.verifier)
|
||||
validate { credential ->
|
||||
val tokenRepo = authEntryPoint.loginVerifier
|
||||
val login = credential.payload.getClaim(LOGIN_NAME).asString()
|
||||
try {
|
||||
if (login != null) {
|
||||
tokenRepo.verify(login)
|
||||
if (credential.payload.expiresAt == null) {
|
||||
throw VerifyTokenException("Static tokens not supported!")
|
||||
}
|
||||
JWTPrincipal(credential.payload)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: VerifyTokenException) {
|
||||
routingLogger.error("verify token failed ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
challenge { defaultScheme, realm ->
|
||||
call.respond(
|
||||
HttpStatusCode.Unauthorized,
|
||||
ServerExceptionInfo(INVALID_TOKEN_MSG)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String?.obtainToken(): DecodedJWT {
|
||||
val token = this?.removePrefix("Bearer ")
|
||||
?: throw VerifyTokenException(INVALID_TOKEN_MSG)
|
||||
return JWT.decode(token)
|
||||
}
|
||||
|
||||
fun ApplicationCall.obtainToken(): DecodedJWT {
|
||||
return request.headers["Authorization"].obtainToken()
|
||||
}
|
||||
|
||||
fun ApplicationCall.obtainUserId(): Long {
|
||||
return obtainToken()
|
||||
.getClaim(USER_ID_ARG)
|
||||
.asLong()
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package ru.shadowsparky.vbox.backend.presentation
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.plugins.statuspages.StatusPages
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.routing
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
import ru.shadowsparky.http.domain.HttpException
|
||||
import ru.shadowsparky.vbox.backend.di.AuthEntryPoint
|
||||
import ru.shadowsparky.vbox.backend.di.RoutingEntryPoint
|
||||
import ru.shadowsparky.vbox.backend.presentation.routing.setupAuthMethods
|
||||
import ru.shadowsparky.vbox.backend.presentation.routing.setupBaseMethods
|
||||
import ru.shadowsparky.vbox.shared.domain.model.ServerExceptionInfo
|
||||
|
||||
val routingLogger: Logger = LoggerFactory.getLogger("routing")
|
||||
|
||||
fun Application.configureRouting(
|
||||
routingEntryPoint: RoutingEntryPoint,
|
||||
authEntryPoint: AuthEntryPoint
|
||||
) {
|
||||
install(StatusPages) {
|
||||
exception<HttpException> { call, cause ->
|
||||
routingLogger.error("http exception occurred. returns ${cause.httpCode}", cause)
|
||||
call.respond(
|
||||
status = HttpStatusCode.fromValue(cause.httpCode),
|
||||
message = ServerExceptionInfo(cause.message ?: "Неизвестная ошибка")
|
||||
)
|
||||
}
|
||||
exception<Throwable> { call, cause ->
|
||||
routingLogger.error("error occurred. returns 500...", cause)
|
||||
call.respond(
|
||||
status = HttpStatusCode.InternalServerError,
|
||||
message = ServerExceptionInfo(cause.message ?: "Неизвестная ошибка")
|
||||
)
|
||||
}
|
||||
}
|
||||
routing {
|
||||
setupBaseMethods(routingEntryPoint.videoApi)
|
||||
setupAuthMethods(routingEntryPoint, authEntryPoint)
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package ru.shadowsparky.vbox.backend.presentation
|
||||
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||||
|
||||
fun Application.configureSerialization() {
|
||||
install(ContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package ru.shadowsparky.vbox.backend.presentation
|
||||
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.server.websocket.WebSockets
|
||||
import io.ktor.server.websocket.pingPeriod
|
||||
import io.ktor.server.websocket.timeout
|
||||
import io.ktor.server.websocket.webSocket
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.readText
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import ru.shadowsparky.backend.data.TokenVerifier
|
||||
import ru.shadowsparky.backend.domain.USER_ID_ARG
|
||||
import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler
|
||||
import ru.shadowsparky.vbox.backend.data.SessionCache
|
||||
import ru.shadowsparky.vbox.backend.di.WebSocketEntryPoint
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
fun Application.configureWebSocket(socketEntryPoint: WebSocketEntryPoint) = with(socketEntryPoint) {
|
||||
install(WebSockets) {
|
||||
pingPeriod = (15).seconds
|
||||
timeout = (15).seconds
|
||||
maxFrameSize = Long.MAX_VALUE
|
||||
masking = false
|
||||
}
|
||||
routing {
|
||||
hashMapOf(
|
||||
EventType.SEARCH to searchEventHandler,
|
||||
EventType.RECENT to recentlyEventHandler,
|
||||
EventType.SAVED to savedEventHandler,
|
||||
EventType.USER_TAG to userTagEventHandler,
|
||||
EventType.MOVIE_TAG to movieTagEventHandler
|
||||
).forEach { (key, handler) -> webSocket(key, handler, tokenVerifier) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.webSocket(
|
||||
eventType: String,
|
||||
handler: BackendRemoteEventHandler,
|
||||
tokenVerifier: TokenVerifier
|
||||
) {
|
||||
val path = when (eventType) {
|
||||
EventType.SEARCH -> RemoteEventHandler.SEARCH_CHANGED
|
||||
EventType.RECENT -> RemoteEventHandler.RECENTLY_CHANGED
|
||||
EventType.SAVED -> RemoteEventHandler.SAVED_CHANGED
|
||||
EventType.USER_TAG -> RemoteEventHandler.USER_TAG_CHANGED
|
||||
EventType.MOVIE_TAG -> RemoteEventHandler.MOVIE_TAG_CHANGED
|
||||
else -> error("Unsupported type $eventType")
|
||||
}
|
||||
webSocket(path) {
|
||||
val frame = (incoming.receive() as Frame.Text).readText()
|
||||
val userId = tokenVerifier.verify(frame).getClaim(USER_ID_ARG).asLong()
|
||||
val session = SessionCache.Writer { text -> outgoing.trySend(Frame.Text(text)) }
|
||||
handler.put(userId, session)
|
||||
val deferred = CompletableDeferred<Unit?>()
|
||||
try {
|
||||
outgoing.invokeOnClose { deferred.complete(null) }
|
||||
deferred.await()
|
||||
} finally {
|
||||
handler.remove(userId, session)
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package ru.shadowsparky.vbox.backend.presentation.routing
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.auth.authenticate
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Routing
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import ru.shadowsparky.vbox.backend.di.AuthEntryPoint
|
||||
import ru.shadowsparky.vbox.backend.di.RoutingEntryPoint
|
||||
import ru.shadowsparky.vbox.backend.presentation.AUTH_JWT_NAME
|
||||
import ru.shadowsparky.vbox.backend.presentation.obtainUserId
|
||||
import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.HeathCheck
|
||||
|
||||
fun Routing.setupAuthMethods(
|
||||
routingEntryPoint: RoutingEntryPoint,
|
||||
authEntryPoint: AuthEntryPoint
|
||||
) = with(routingEntryPoint) {
|
||||
val default = authEntryPoint.authTokenRepositoryFactory.create()
|
||||
post(AuthTokenRepository.REGISTER_PATH) {
|
||||
call.respond(default.register(call.receive()))
|
||||
}
|
||||
post(AuthTokenRepository.LOGIN_PATH) {
|
||||
call.respond(default.login(call.receive()))
|
||||
}
|
||||
post(AuthTokenRepository.UPDATE_PATH) {
|
||||
call.respond(default.update(call.receive()))
|
||||
}
|
||||
post(AuthTokenRepository.REVOKE_PATH) {
|
||||
default.revoke(call.receive())
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
authenticate(AUTH_JWT_NAME) {
|
||||
|
||||
get("/chech") { call.respond(HttpStatusCode.OK) }
|
||||
get(HeathCheck.PATH) { call.respond(HttpStatusCode.OK) }
|
||||
|
||||
post(AuthTokenRepository.CHANGE_PASS_PATH) {
|
||||
authEntryPoint.authTokenRepositoryFactory.create(call.obtainUserId())
|
||||
.changePassword(call.receive())
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
setupSearch(searchFactory)
|
||||
setupRecentlyWatched(recentlyFactory)
|
||||
setupSavedMovie(savedMovieFactory)
|
||||
setupTagsRouting(userTagFactory, movieTagFactory)
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package ru.shadowsparky.vbox.backend.presentation.routing
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Routing
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.util.getOrFail
|
||||
import ru.shadowsparky.vbox.shared.domain.VideoApi
|
||||
|
||||
fun Routing.setupBaseMethods(api: VideoApi) {
|
||||
get(VideoApi.VIDEOS_PATH) {
|
||||
val p = call.parameters
|
||||
val obj = api.fetchNewVideos(p[VideoApi.SEARCH_ARG])
|
||||
call.respond(obj)
|
||||
}
|
||||
get(VideoApi.DETAILS_PATH) {
|
||||
val p = call.parameters
|
||||
call.respond(api.fetchDetails(p.getOrFail(VideoApi.ID_ARG).toLong()))
|
||||
}
|
||||
get(VideoApi.LINKS_PATH) {
|
||||
val p = call.parameters
|
||||
call.respond(
|
||||
api.fetchVideoLinks(
|
||||
p.getOrFail(VideoApi.ID_ARG).toLong(),
|
||||
p[VideoApi.SEASON_ID_ARG]?.toLong()
|
||||
)
|
||||
)
|
||||
}
|
||||
get("/") {
|
||||
call.respond(HttpStatusCode.NotFound)
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package ru.shadowsparky.vbox.backend.presentation.routing
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import kotlinx.coroutines.flow.first
|
||||
import ru.shadowsparky.vbox.backend.di.factory.RecentlyWatchedRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.presentation.obtainUserId
|
||||
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedResponse
|
||||
|
||||
fun Route.setupRecentlyWatched(recentlyFactory: RecentlyWatchedRepositoryFactory) {
|
||||
get(RecentlyWatchedRepository.GET_INFO) {
|
||||
val p = call.parameters
|
||||
val recentlyWatchedId = p[RecentlyWatchedRepository.MOVIE_ID_ARG]?.toLong()
|
||||
val seasonId = p[RecentlyWatchedRepository.SEASON_ID_ARG]?.toLong()
|
||||
val repo = recentlyFactory.create(call.obtainUserId())
|
||||
val list = if (recentlyWatchedId != null) {
|
||||
repo.queryRecentlyWatched(recentlyWatchedId, seasonId).first()
|
||||
} else {
|
||||
repo.getAllRecentlyWatched()
|
||||
}
|
||||
call.respond(RecentlyWatchedResponse(list))
|
||||
}
|
||||
post(RecentlyWatchedRepository.WRITE) {
|
||||
val info = call.receive<RecentlyWatchedRequest>()
|
||||
recentlyFactory.create(call.obtainUserId()).writeAll(info.info)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
post(RecentlyWatchedRepository.REMOVE) {
|
||||
val repo = recentlyFactory.create(call.obtainUserId())
|
||||
call.receive<RecentlyWatchedRequest>().info.forEach {
|
||||
repo.remove(it)
|
||||
}
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
post(RecentlyWatchedRepository.TOGGLE) {
|
||||
val repo = recentlyFactory.create(call.obtainUserId())
|
||||
call.receive<RecentlyWatchedRequest>().info.forEach {
|
||||
repo.toggleInfo(it)
|
||||
}
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package ru.shadowsparky.vbox.backend.presentation.routing
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.util.getOrFail
|
||||
import kotlinx.coroutines.flow.first
|
||||
import ru.shadowsparky.vbox.backend.di.factory.SavedMovieRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.presentation.obtainUserId
|
||||
import ru.shadowsparky.vbox.shared.domain.IsSavedResponse
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoveRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.SavedMoviesResponse
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||
|
||||
fun Route.setupSavedMovie(savedMovieFactory: SavedMovieRepositoryFactory) {
|
||||
post(SavedMovieRepository.REMOVE) {
|
||||
val saveRequest = call.receive<RemoveRequest>()
|
||||
savedMovieFactory.create(call.obtainUserId()).remove(saveRequest.movieId)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
post(SavedMovieRepository.WRITE) {
|
||||
val saveRequest = call.receive<VideoDetails>()
|
||||
savedMovieFactory.create(call.obtainUserId()).save(saveRequest)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
get(SavedMovieRepository.QUERY_SAVED) {
|
||||
val p = call.parameters
|
||||
val saved = savedMovieFactory.create(call.obtainUserId())
|
||||
.isSaved(p.getOrFail(SavedMovieRepository.MOVIE_ID_ARG).toLong())
|
||||
.first()
|
||||
call.respond(IsSavedResponse(saved))
|
||||
}
|
||||
get(SavedMovieRepository.QUERY_ALL) {
|
||||
val saved = savedMovieFactory.create(call.obtainUserId())
|
||||
.getAll()
|
||||
.first()
|
||||
call.respond(SavedMoviesResponse(saved))
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package ru.shadowsparky.vbox.backend.presentation.routing
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.delete
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import kotlinx.coroutines.flow.first
|
||||
import ru.shadowsparky.vbox.backend.di.factory.SearchRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.presentation.obtainUserId
|
||||
import ru.shadowsparky.vbox.shared.domain.SearchDeleteRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.SearchQueryRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.SearchQueryResponse
|
||||
import ru.shadowsparky.vbox.shared.domain.SearchRepository
|
||||
|
||||
fun Route.setupSearch(searchFactory: SearchRepositoryFactory) {
|
||||
get(SearchRepository.SEARCH) {
|
||||
val p = call.parameters
|
||||
val userId = call.obtainUserId()
|
||||
val search = searchFactory.create(userId)
|
||||
call.respond(SearchQueryResponse(search.search(p[SearchRepository.QUERY_ARG] ?: "").first()))
|
||||
}
|
||||
post(SearchRepository.ADD_TO_SEARCH) {
|
||||
val userId = call.obtainUserId()
|
||||
val request = call.receive<SearchQueryRequest>()
|
||||
searchFactory.create(userId).addToSearch(request.query)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
delete(SearchRepository.CLEAR) {
|
||||
val userId = call.obtainUserId()
|
||||
val request = call.receive<SearchDeleteRequest>()
|
||||
val repo = searchFactory.create(userId)
|
||||
if (request.query == null) {
|
||||
repo.clear()
|
||||
} else {
|
||||
repo.deleteFromSearch(request.query!!)
|
||||
}
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package ru.shadowsparky.vbox.backend.presentation.routing
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.delete
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import kotlinx.coroutines.flow.first
|
||||
import ru.shadowsparky.vbox.backend.data.tags.MovieTagRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.data.tags.UserTagRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.presentation.obtainUserId
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.AddRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.DeleteRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.EditRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.LinkRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.LinkedMovies
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.TagResponse
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
|
||||
|
||||
fun Route.setupTagsRouting(
|
||||
userTagFactory: UserTagRepositoryFactory,
|
||||
movieTagFactory: MovieTagRepositoryFactory
|
||||
) {
|
||||
setupUserTag(userTagFactory)
|
||||
setupMovieTag(movieTagFactory)
|
||||
}
|
||||
|
||||
private fun Route.setupUserTag(userTagFactory: UserTagRepositoryFactory) {
|
||||
get(UserTagRepository.LIST) {
|
||||
val userTag = userTagFactory.create(call.obtainUserId())
|
||||
call.respond(TagResponse(userTag.userTags.first()))
|
||||
}
|
||||
post(UserTagRepository.ADD) {
|
||||
val userTag = userTagFactory.create(call.obtainUserId())
|
||||
userTag.add(call.receive<AddRequest>().tag)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
post(UserTagRepository.EDIT) {
|
||||
val userTag = userTagFactory.create(call.obtainUserId())
|
||||
val edit = call.receive<EditRequest>()
|
||||
userTag.edit(edit.id, edit.newTag)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
delete(UserTagRepository.DELETE) {
|
||||
val userTag = userTagFactory.create(call.obtainUserId())
|
||||
userTag.delete(call.receive<DeleteRequest>().id)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.setupMovieTag(movieTagFactory: MovieTagRepositoryFactory) {
|
||||
get(MovieTagRepository.QUERY) {
|
||||
val movieTag = movieTagFactory.create(call.obtainUserId())
|
||||
val movieId = call.parameters[MovieTagRepository.MOVIE_ID]?.toLong()
|
||||
val tags = movieTag.query(checkNotNull(movieId) { "movieId required" }).first()
|
||||
call.respond(TagResponse(tags))
|
||||
}
|
||||
get(MovieTagRepository.QUERY_MOVIES) {
|
||||
val movieTag = movieTagFactory.create(call.obtainUserId())
|
||||
val tagId = call.parameters[MovieTagRepository.TAG_ID]?.toLong()
|
||||
val ids = movieTag.queryMovies(checkNotNull(tagId) { "tagId required" }).first()
|
||||
call.respond(LinkedMovies(ids))
|
||||
}
|
||||
post(MovieTagRepository.LINK) {
|
||||
val movieTag = movieTagFactory.create(call.obtainUserId())
|
||||
val link = call.receive<LinkRequest>()
|
||||
movieTag.link(link.movieId, link.tagId)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
delete(MovieTagRepository.UNLINK) {
|
||||
val movieTag = movieTagFactory.create(call.obtainUserId())
|
||||
val link = call.receive<LinkRequest>()
|
||||
movieTag.unlink(link.movieId, link.tagId)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
ktor {
|
||||
deployment {
|
||||
port = 8181
|
||||
}
|
||||
application {
|
||||
modules = [ ru.shadowsparky.vbox.backend.MainKt.module ]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<configuration>
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
<root level="debug">
|
||||
<appender-ref ref="STDOUT" />
|
||||
</root>
|
||||
<logger name="org.eclipse.jetty" level="INFO" />
|
||||
<logger name="io.netty" level="INFO" />
|
||||
</configuration>
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Создание таблицы movie
|
||||
|
||||
CREATE TABLE movie (
|
||||
movie_id BIGSERIAL PRIMARY KEY,
|
||||
poster_url VARCHAR(256) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
flags INT NOT NULL
|
||||
);
|
||||
|
||||
-- Создание таблицы user
|
||||
|
||||
CREATE TABLE "user" (
|
||||
user_id BIGSERIAL PRIMARY KEY,
|
||||
login VARCHAR(256) NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
flags INT DEFAULT NULL
|
||||
);
|
||||
|
||||
-- Создание таблицы recently_watched
|
||||
|
||||
CREATE TABLE recently_watched (
|
||||
recently_watched_id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
movie_id BIGINT NOT NULL,
|
||||
episode BIGINT DEFAULT NULL,
|
||||
season BIGINT DEFAULT NULL,
|
||||
UNIQUE (user_id, movie_id),
|
||||
FOREIGN KEY (user_id) REFERENCES "user" (user_id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
FOREIGN KEY (movie_id) REFERENCES movie (movie_id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- Создание таблицы saved_movie
|
||||
|
||||
CREATE TABLE saved_movie (
|
||||
saved_movie_id BIGSERIAL PRIMARY KEY,
|
||||
movie_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
UNIQUE (movie_id, user_id),
|
||||
FOREIGN KEY (movie_id) REFERENCES movie (movie_id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES "user" (user_id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- Создание таблицы search_info
|
||||
|
||||
CREATE TABLE search_info (
|
||||
search_id BIGSERIAL PRIMARY KEY,
|
||||
query VARCHAR(256) DEFAULT NULL,
|
||||
user_id BIGINT DEFAULT NULL,
|
||||
UNIQUE (query, user_id),
|
||||
FOREIGN KEY (user_id) REFERENCES "user" (user_id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE refresh_tokens (
|
||||
token_id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at BIGINT NOT NULL,
|
||||
revoked BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TABLE user_tag (
|
||||
id bigserial NOT NULL,
|
||||
user_id bigint NOT NULL,
|
||||
tag varchar NOT NULL,
|
||||
CONSTRAINT user_tag_pk PRIMARY KEY (id),
|
||||
CONSTRAINT user_tag_user_fk FOREIGN KEY (user_id) REFERENCES "user" (user_id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX user_tag_tag_idx ON user_tag (tag,user_id);
|
||||
|
||||
CREATE TABLE movie_tag (
|
||||
id bigserial NOT NULL,
|
||||
movie_id bigint NOT NULL,
|
||||
tag_id bigint NOT NULL,
|
||||
CONSTRAINT movie_tag_pk PRIMARY KEY (id),
|
||||
CONSTRAINT movie_tag_movie_fk FOREIGN KEY (movie_id) REFERENCES movie(movie_id),
|
||||
CONSTRAINT movie_tag_user_tag_fk FOREIGN KEY (tag_id) REFERENCES user_tag(id)
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
insertMovie:
|
||||
INSERT INTO "movie" (movie_id, poster_url, description, title, flags) VALUES (?, ?, ?, ?, ?);
|
||||
|
||||
selectMovie:
|
||||
SELECT * FROM "movie" WHERE movie_id = ?;
|
||||
|
||||
updateMovie:
|
||||
UPDATE "movie"
|
||||
SET poster_url = ?, description = ?, title = ?, flags = ?
|
||||
WHERE movie_id = ?;
|
||||
@@ -0,0 +1,23 @@
|
||||
selectAll:
|
||||
SELECT * FROM "recently_watched" WHERE user_id = ?;
|
||||
|
||||
selectByMovieId:
|
||||
SELECT * FROM "recently_watched" WHERE movie_id = ? AND user_id = ? AND season IS NOT NULL AND episode IS NOT NULL;
|
||||
|
||||
selectByMovieIdAndSeason:
|
||||
SELECT * FROM "recently_watched" WHERE movie_id = ? AND user_id = ? AND season = ? AND episode IS NOT NULL;
|
||||
|
||||
selectByIds:
|
||||
SELECT * FROM "recently_watched" WHERE movie_id = ? AND episode = ? AND season = ? AND user_id = ?;
|
||||
|
||||
insertRecentlyWatched:
|
||||
INSERT INTO "recently_watched" (user_id, movie_id, episode, season) VALUES (?, ?, ?, ?);
|
||||
|
||||
deleteRwByIds:
|
||||
DELETE FROM "recently_watched" WHERE (movie_id = ? AND episode = ? AND season = ? AND user_id = ?);
|
||||
|
||||
deleteRecentlyWatchedById:
|
||||
DELETE FROM "recently_watched" WHERE recently_watched_id = ? AND user_id = ?;
|
||||
|
||||
deleteRecentlyWatchedByMovieId:
|
||||
DELETE FROM "recently_watched" WHERE movie_id = ? AND user_id = ?;
|
||||
@@ -0,0 +1,19 @@
|
||||
selectByHash:
|
||||
SELECT *
|
||||
FROM refresh_tokens
|
||||
WHERE token_hash = ?;
|
||||
|
||||
insertToken:
|
||||
INSERT INTO refresh_tokens(user_id, token_hash, expires_at, revoked)
|
||||
VALUES (?, ?, ?, FALSE)
|
||||
RETURNING token_id;
|
||||
|
||||
revokeToken:
|
||||
UPDATE refresh_tokens
|
||||
SET revoked = TRUE
|
||||
WHERE token_id = ?;
|
||||
|
||||
revokeAllForUser:
|
||||
UPDATE refresh_tokens
|
||||
SET revoked = TRUE
|
||||
WHERE user_id = ?;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- videobox."saved_movie" definition
|
||||
|
||||
selectByUserId:
|
||||
SELECT * FROM "saved_movie" WHERE user_id = ? ORDER BY saved_movie_id;
|
||||
|
||||
selectByUserIdAndMovieId:
|
||||
SELECT * FROM "saved_movie" WHERE user_id = ? AND movie_id = ?;
|
||||
|
||||
addSavedMovie:
|
||||
INSERT INTO "saved_movie" (movie_id, user_id) VALUES (?, ?);
|
||||
|
||||
removeSavedMovie:
|
||||
DELETE FROM "saved_movie" WHERE user_id = ? AND movie_id = ?;
|
||||
@@ -0,0 +1,14 @@
|
||||
selectAll:
|
||||
SELECT * FROM "search_info" WHERE user_id = ? ORDER BY search_id;
|
||||
|
||||
selectByQuery:
|
||||
SELECT * FROM "search_info" WHERE query = ? AND user_id = ? ORDER BY search_id;
|
||||
|
||||
insertSearchInfo:
|
||||
INSERT INTO "search_info" (query, user_id) VALUES (?, ?);
|
||||
|
||||
deleteSearchInfo:
|
||||
DELETE FROM "search_info" WHERE query = ? AND user_id = ?;
|
||||
|
||||
deleteAll:
|
||||
DELETE FROM "search_info" WHERE user_id = ?;
|
||||
@@ -0,0 +1,47 @@
|
||||
getUserTagsByUserId:
|
||||
SELECT id, user_id, tag
|
||||
FROM user_tag
|
||||
WHERE user_id = ?;
|
||||
|
||||
getUserIdByTagId:
|
||||
SELECT user_id
|
||||
FROM user_tag
|
||||
WHERE id = ?;
|
||||
|
||||
insertUserTag:
|
||||
INSERT INTO user_tag (user_id, tag)
|
||||
VALUES (?, ?);
|
||||
|
||||
updateTagText:
|
||||
UPDATE user_tag
|
||||
SET tag = ?
|
||||
WHERE id = ?;
|
||||
|
||||
deleteTagById:
|
||||
DELETE FROM user_tag
|
||||
WHERE id = ?;
|
||||
|
||||
getTagsForMovie:
|
||||
SELECT ut.id, ut.tag
|
||||
FROM movie_tag mt
|
||||
JOIN user_tag ut ON mt.tag_id = ut.id
|
||||
WHERE mt.movie_id = ? AND ut.user_id = ?;
|
||||
|
||||
getMoviesByTagId:
|
||||
SELECT mt.movie_id
|
||||
FROM movie_tag mt
|
||||
JOIN user_tag ut ON mt.tag_id = ut.id
|
||||
WHERE mt.tag_id = ? AND ut.user_id = ?;
|
||||
|
||||
linkMovieTag:
|
||||
INSERT INTO movie_tag (movie_id, tag_id)
|
||||
SELECT ?, ut.id
|
||||
FROM user_tag ut
|
||||
WHERE ut.id = ? AND ut.user_id = ?
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
unlinkMovieTag:
|
||||
DELETE FROM movie_tag
|
||||
WHERE movie_id = ?
|
||||
AND tag_id = ?
|
||||
AND tag_id IN (SELECT id FROM user_tag WHERE user_id = ?);
|
||||
@@ -0,0 +1,16 @@
|
||||
selectUserByLogin:
|
||||
SELECT * FROM "user" WHERE login = ? LIMIT 1;
|
||||
|
||||
selectUserByUserId:
|
||||
SELECT * FROM "user" WHERE user_id = ? LIMIT 1;
|
||||
|
||||
addUser:
|
||||
INSERT INTO "user" (login, password_hash, created_at, flags) VALUES (?, ?, ?, ?);
|
||||
|
||||
removeUser:
|
||||
DELETE FROM "user" WHERE login = ?;
|
||||
|
||||
updatePassword:
|
||||
UPDATE "user"
|
||||
SET password_hash = ?
|
||||
WHERE user_id = ?;
|
||||
Reference in New Issue
Block a user