diff --git a/composeApp/src/commonMain/kotlin/in/procyk/shin/component/MainComponent.kt b/composeApp/src/commonMain/kotlin/in/procyk/shin/component/MainComponent.kt index 9ae03cf..db41471 100644 --- a/composeApp/src/commonMain/kotlin/in/procyk/shin/component/MainComponent.kt +++ b/composeApp/src/commonMain/kotlin/in/procyk/shin/component/MainComponent.kt @@ -3,16 +3,17 @@ package `in`.procyk.shin.component import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.childContext import com.arkivanov.decompose.value.Value -import `in`.procyk.shin.ui.util.createHttpClient import `in`.procyk.shin.model.ShortenedProtocol import `in`.procyk.shin.shared.* import `in`.procyk.shin.shared.Option.None import `in`.procyk.shin.shared.Option.Some +import `in`.procyk.shin.ui.util.createHttpClient import io.ktor.client.* import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.datetime.* @@ -30,6 +31,8 @@ interface MainComponent : Component { val customPrefixVisible: Value + val oneTimeOnly: Value + val expirationDate: Value val expirationDateVisible: Value @@ -50,6 +53,8 @@ interface MainComponent : Component { fun onCustomPrefixVisibleChange(visible: Boolean) + fun onOneTimeOnlyChange(oneTimeOnly: Boolean) + fun onExpirationDateChange(expirationDate: LocalDate?): Boolean fun onExpirationDateVisibleChange(visible: Boolean) @@ -77,7 +82,8 @@ class MainComponentImpl( private val httpClient: HttpClient = createHttpClient() - override val favourites: FavouritesComponent = FavouritesComponentImpl(appContext, componentContext.childContext(key = "Favourites")) + override val favourites: FavouritesComponent = + FavouritesComponentImpl(appContext, componentContext.childContext(key = "Favourites")) private val _extraElementsVisible = MutableStateFlow(false) override val extraElementsVisible: Value = _extraElementsVisible.asValue() @@ -88,6 +94,9 @@ class MainComponentImpl( private val _customPrefixVisible = MutableStateFlow(false) override val customPrefixVisible: Value = _customPrefixVisible.asValue() + private val _oneTimeOnly = MutableStateFlow(false) + override val oneTimeOnly: Value = _oneTimeOnly.asValue() + private val _expirationDate = MutableStateFlow(tomorrow) override val expirationDate: Value = _expirationDate.asValue() @@ -121,6 +130,10 @@ class MainComponentImpl( _customPrefixVisible.update { visible } } + override fun onOneTimeOnlyChange(oneTimeOnly: Boolean) { + _oneTimeOnly.update { oneTimeOnly } + } + override fun onExpirationDateChange(expirationDate: LocalDate?): Boolean = when { expirationDate == null -> { val updatedDate = tomorrow @@ -172,9 +185,10 @@ class MainComponentImpl( httpClient.requestShortenedUrl( url = _fullUrl.value, shortenedProtocol = _protocol.value, - customPrefix = _customPrefix.value.takeIfExtraElementsVisibleAnd(customPrefixVisible), - expirationDate = _expirationDate.value.takeIfExtraElementsVisibleAnd(expirationDateVisible), - redirectType = _redirectType.value.takeIfExtraElementsVisibleAnd(redirectTypeVisible), + customPrefix = _customPrefix.takeIfExtraElementsVisibleAnd(customPrefixVisible), + oneTimeOnly = _oneTimeOnly.takeIfExtraElementsVisible(), + expirationDate = _expirationDate.takeIfExtraElementsVisibleAnd(expirationDateVisible), + redirectType = _redirectType.takeIfExtraElementsVisibleAnd(redirectTypeVisible), onResponse = { code, response -> when (code) { HttpStatusCode.OK -> { @@ -193,14 +207,19 @@ class MainComponentImpl( } @Suppress("NOTHING_TO_INLINE") - private inline fun T.takeIfExtraElementsVisibleAnd(value: Value): T? = - takeIf { _extraElementsVisible.value && value.value } + private inline fun StateFlow.takeIfExtraElementsVisibleAnd(value: Value): T? = + this.value.takeIf { _extraElementsVisible.value && value.value } + + @Suppress("NOTHING_TO_INLINE") + private inline fun StateFlow.takeIfExtraElementsVisible(): T? = + this.value.takeIf { _extraElementsVisible.value } } private suspend inline fun HttpClient.requestShortenedUrl( url: String, shortenedProtocol: ShortenedProtocol, customPrefix: String?, + oneTimeOnly: Boolean?, expirationDate: LocalDate?, redirectType: RedirectType?, onResponse: (HttpStatusCode, String) -> Unit, @@ -209,7 +228,7 @@ private suspend inline fun HttpClient.requestShortenedUrl( try { val expirationAt = expirationDate?.plus(1, DateTimeUnit.DAY) ?.atStartOfDayIn(TimeZone.currentSystemDefault()) - val shorten = Shorten(shortenedProtocol.buildUrl(url), customPrefix, expirationAt, redirectType) + val shorten = Shorten(shortenedProtocol.buildUrl(url), customPrefix, oneTimeOnly, expirationAt, redirectType) val response = post(ShortenPath) { contentType(ContentType.Application.Cbor) setBody(ShinCbor.encodeToByteArray(shorten)) diff --git a/composeApp/src/commonMain/kotlin/in/procyk/shin/ui/component/ShortenRequest.kt b/composeApp/src/commonMain/kotlin/in/procyk/shin/ui/component/ShortenRequest.kt index f31c940..c96723a 100644 --- a/composeApp/src/commonMain/kotlin/in/procyk/shin/ui/component/ShortenRequest.kt +++ b/composeApp/src/commonMain/kotlin/in/procyk/shin/ui/component/ShortenRequest.kt @@ -145,6 +145,13 @@ private fun ExpandableSettings( modifier = Modifier.fillMaxWidth(), ) } + ExpandableSetting( + name = "One-Time Only", + visible = component.oneTimeOnly, + isVertical = isVertical, + fillMaxWidth = true, + onVisibleChange = component::onOneTimeOnlyChange, + ) ExpandableSetting( name = "Expiration Date", visible = component.expirationDateVisible, @@ -185,15 +192,13 @@ private inline val RedirectType.presentableName: String } @Composable -private inline fun ExpandableSetting( +private fun ExpandableSetting( name: String, visible: Value, isVertical: Boolean, fillMaxWidth: Boolean, - noinline onVisibleChange: (Boolean) -> Unit, - crossinline content: - @Composable - () -> Unit, + onVisibleChange: (Boolean) -> Unit, + content: (@Composable () -> Unit)? = null, ) { Column( modifier = when { @@ -218,13 +223,16 @@ private inline fun ExpandableSetting( onCheckedChange = onVisibleChange, ) } + if (content == null) return@Column + val alpha by animateFloatAsState(if (contentVisible) 1f else 0f) Box( modifier = Modifier .alpha(alpha) .applyIf(fillMaxWidth && isVertical) { fillMaxWidth() } .applyIf(!fillMaxWidth || !isVertical) { sizeIn(maxWidth = 270.dp) } - .applyIf(!contentVisible) { height(1.dp) }, + .applyIf(!contentVisible) { height(0.dp) } + .applyIf(contentVisible) { padding(8.dp) }, ) { content() } diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index 8af98f2..0a8a74f 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -1,3 +1,3 @@ TEAM_ID= -BUNDLE_ID=in.procyk.shin +BUNDLE_ID=in.procyk.shin.test6 APP_NAME=shin \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 7cd50f0..354415f 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -301,7 +301,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; + DEVELOPMENT_TEAM = ; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", diff --git a/server/src/main/kotlin/in/procyk/shin/Routes.kt b/server/src/main/kotlin/in/procyk/shin/Routes.kt index 71fd1e2..3299dea 100644 --- a/server/src/main/kotlin/in/procyk/shin/Routes.kt +++ b/server/src/main/kotlin/in/procyk/shin/Routes.kt @@ -24,9 +24,6 @@ internal fun Application.installRoutes(): Routing = routing { val service by inject() val dotenv by inject() val redirectBaseUrl = dotenv.env("REDIRECT_BASE_URL") - GlobalScope.launch { - deleteExpiredUrlsEvery(1.hours, service) - } postBody(ShortenPath) { val shorten = call.receive() handleShorten(service, redirectBaseUrl, shorten) @@ -36,18 +33,6 @@ internal fun Application.installRoutes(): Routing = routing { } } -private suspend inline fun deleteExpiredUrlsEvery( - duration: Duration, - service: ShortUrlService, -) { - coroutineScope { - while (isActive) { - service.deleteExpiredUrls() - delay(duration) - } - } -} - private suspend inline fun PipelineContext.handleShorten( service: ShortUrlService, redirectBaseUrl: String, diff --git a/server/src/main/kotlin/in/procyk/shin/db/ShortUrl.kt b/server/src/main/kotlin/in/procyk/shin/db/ShortUrl.kt index 1189caa..e020e9b 100644 --- a/server/src/main/kotlin/in/procyk/shin/db/ShortUrl.kt +++ b/server/src/main/kotlin/in/procyk/shin/db/ShortUrl.kt @@ -14,6 +14,8 @@ internal object ShortUrls : IdTable() { val url = text("url") val expirationAt = timestamp("expiration_at").nullable() + val oneTimeOnly = bool("one_time_only").default(false) + val active = bool("active").default(true) val redirectType = customEnumeration( name = "redirect_type", sql = "redirect_type", @@ -28,6 +30,8 @@ internal class ShortUrl(id: EntityID) : Entity(id) { var url by ShortUrls.url var expirationAt by ShortUrls.expirationAt + var oneTimeOnly by ShortUrls.oneTimeOnly + var active by ShortUrls.active var redirectType by ShortUrls.redirectType var usageCount by ShortUrls.usageCount } diff --git a/server/src/main/kotlin/in/procyk/shin/service/ShortUrlService.kt b/server/src/main/kotlin/in/procyk/shin/service/ShortUrlService.kt index bf2bd89..afa1016 100644 --- a/server/src/main/kotlin/in/procyk/shin/service/ShortUrlService.kt +++ b/server/src/main/kotlin/in/procyk/shin/service/ShortUrlService.kt @@ -1,15 +1,10 @@ package `in`.procyk.shin.service import `in`.procyk.shin.db.ShortUrl -import `in`.procyk.shin.db.ShortUrls import `in`.procyk.shin.shared.RedirectType import `in`.procyk.shin.shared.Shorten import io.ktor.http.* import kotlinx.datetime.Clock -import org.jetbrains.exposed.sql.SqlExpressionBuilder.isNotNull -import org.jetbrains.exposed.sql.SqlExpressionBuilder.lessEq -import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction import org.koin.core.module.Module import java.net.URI @@ -23,8 +18,6 @@ internal interface ShortUrlService { suspend fun findShortenedUrl(shortenedId: String): ShortenedUrl? suspend fun increaseShortenedUrlUsageCount(shortenedId: String) - - suspend fun deleteExpiredUrls() } internal fun Module.singleShortUrlService() { @@ -34,6 +27,7 @@ internal fun Module.singleShortUrlService() { private class ShortUrlServiceImpl : ShortUrlService { override suspend fun findOrCreateShortenedId(shorten: Shorten): String? { val shortened = shorten.createShortenedIdentifier() ?: return null + val oneTimeOnly = shorten.oneTimeOnly val expirationAt = shorten.expirationAt return newSuspendedTransaction txn@{ for (count in shortened.takeCounts) { @@ -43,6 +37,8 @@ private class ShortUrlServiceImpl : ShortUrlService { null -> ShortUrl.new(shortId) { this.url = shortened.url this.expirationAt = expirationAt + this.oneTimeOnly = oneTimeOnly ?: false + this.active = true this.redirectType = RedirectType.from(shorten.redirectType) this.usageCount = 0 }.let { return@txn shortId } @@ -50,15 +46,22 @@ private class ShortUrlServiceImpl : ShortUrlService { shortened.url -> { val prevExpirationAt = existing.expirationAt when { - prevExpirationAt == null -> return@txn shortId + prevExpirationAt == null -> {} expirationAt == null || expirationAt > prevExpirationAt -> { existing.expirationAt = expirationAt - return@txn shortId } - else -> return@txn shortId + else -> {} + } + + existing.active = true + + val prevOneTimeOnly = existing.oneTimeOnly + if (prevOneTimeOnly && (oneTimeOnly == null || !oneTimeOnly)) { + existing.oneTimeOnly = false } + return@txn shortId } else -> continue @@ -69,22 +72,24 @@ private class ShortUrlServiceImpl : ShortUrlService { } override suspend fun findShortenedUrl(shortenedId: String): ShortenedUrl? = newSuspendedTransaction { - ShortUrl.findById(shortenedId) + val shortUrl = ShortUrl.findById(shortenedId) + when { + shortUrl == null || !shortUrl.active -> null + shortUrl.oneTimeOnly -> shortUrl.also { it.active = false } + shortUrl.expirationAt.let { exp -> exp != null && exp <= Clock.System.now() } -> + null.also { shortUrl.active = false } + + else -> shortUrl + } }?.let { ShortenedUrl(it.url, it.redirectType) } override suspend fun increaseShortenedUrlUsageCount(shortenedId: String) = newSuspendedTransaction txn@{ val shortenedUrl = ShortUrl.findById(shortenedId) ?: return@txn + println("increase for ${shortenedUrl.id}") shortenedUrl.usageCount += 1 } - - override suspend fun deleteExpiredUrls() { - val now = Clock.System.now() - newSuspendedTransaction { - ShortUrls.deleteWhere { (expirationAt.isNotNull()) and (expirationAt.lessEq(now)) } - } - } } data class ShortenedUrl( diff --git a/shared/src/commonMain/kotlin/in/procyk/shin/shared/Model.kt b/shared/src/commonMain/kotlin/in/procyk/shin/shared/Model.kt index 5ff5135..7f0fac1 100644 --- a/shared/src/commonMain/kotlin/in/procyk/shin/shared/Model.kt +++ b/shared/src/commonMain/kotlin/in/procyk/shin/shared/Model.kt @@ -24,6 +24,7 @@ enum class RedirectType { class Shorten( val url: String, val customPrefix: String? = null, + val oneTimeOnly: Boolean? = null, val expirationAt: Instant? = null, val redirectType: RedirectType? = null, )