init projects

This commit is contained in:
2026-06-17 18:58:41 +03:00
parent 8543c4e241
commit 21a768edd5
505 changed files with 19998 additions and 93 deletions
@@ -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())
}
@@ -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
)
)
}
}
@@ -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)
}
}
}
}
}
@@ -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"
}
}
@@ -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)
}
}
@@ -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"
}
}
@@ -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))
}
}
}
}
@@ -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)
}
}
@@ -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("&quot;")
?.lastOrNull { it.isNotEmpty() }
?: title?.removeSurrounding("&quot;")
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/"
}
}
@@ -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
}
}
}
@@ -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
}
@@ -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))
}
}
@@ -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
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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()
}
@@ -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)
}
}
@@ -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()
}
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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))
}
}
@@ -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)
}
}
@@ -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 = ?;