Skip to content

Commit f8e7da1

Browse files
feat: Providing automatic system data backup via Dropbox
closes #838
1 parent 4e6c7a6 commit f8e7da1

File tree

11 files changed

+471
-2
lines changed

11 files changed

+471
-2
lines changed

app/build.gradle.kts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ buildscript {
1212

1313
plugins {
1414
kotlin("jvm")
15+
alias(libs.plugins.kotlin.serialization)
1516
kotlin("kapt")
1617
id("io.spring.dependency-management")
1718
alias(libs.plugins.kotlin.spring)
@@ -37,12 +38,20 @@ dependencies {
3738
implementation(libs.kotlinx.coroutines.core)
3839
implementation(libs.kotlinx.coroutines.reactive)
3940
implementation(libs.kotlinx.coroutines.reactor)
41+
implementation(libs.kotlinx.serialization.json)
42+
implementation(libs.kotlinx.dateTime)
4043

4144
implementation(libs.jooq)
4245
implementation(libs.jjwt.api)
4346
implementation(libs.arrow.core)
4447
implementation(libs.springdocOpenapi.starterCommon)
4548

49+
implementation(libs.ktor.clientCore)
50+
implementation(libs.ktor.clientCio)
51+
implementation(libs.ktor.contentNegotiation)
52+
implementation(libs.ktor.kotlinxJson)
53+
implementation(libs.ktor.auth)
54+
4655
kapt("org.springframework.boot:spring-boot-configuration-processor")
4756

4857
runtimeOnly("org.flywaydb:flyway-core")

app/src/main/kotlin/io/orangebuffalo/simpleaccounting/services/integration/CoroutinesSupport.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import org.springframework.web.server.ServerWebExchange
66
import reactor.util.context.Context
77
import kotlin.coroutines.coroutineContext
88

9-
@Suppress("EXPERIMENTAL_API_USAGE")
9+
@OptIn(DelicateCoroutinesApi::class)
1010
private val dbContext = newFixedThreadPoolContext(20, "db-context")
1111

1212
suspend fun <T> withDbContext(block: suspend CoroutineScope.() -> T): T = withContext(dbContext, block)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package io.orangebuffalo.simpleaccounting.services.integration.backups
2+
3+
import org.springframework.boot.context.properties.ConfigurationProperties
4+
import org.springframework.stereotype.Component
5+
6+
@ConfigurationProperties("simpleaccounting.backup")
7+
@Component
8+
data class BackupProperties(
9+
var enabled: Boolean = false,
10+
var maxBackups: Int = 50,
11+
var dropbox: DropboxBackupProperties = DropboxBackupProperties(),
12+
) {
13+
14+
data class DropboxBackupProperties(
15+
var accessToken: String? = null,
16+
var refreshToken: String? = null,
17+
var clientId: String? = null,
18+
var clientSecret: String? = null,
19+
)
20+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package io.orangebuffalo.simpleaccounting.services.integration.backups
2+
3+
import java.nio.file.Path
4+
5+
/**
6+
* Processes the backup files (typically, uploads to a remote storage).
7+
*/
8+
interface BackupProvider {
9+
10+
/**
11+
* Accepts the backup file and processes it.
12+
*/
13+
suspend fun acceptBackup(backupFile: Path)
14+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package io.orangebuffalo.simpleaccounting.services.integration.backups
2+
3+
import io.orangebuffalo.simpleaccounting.services.integration.backups.impl.DropboxBackupProvider
4+
import io.orangebuffalo.simpleaccounting.services.integration.backups.impl.NoOpBackupProvider
5+
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
6+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
7+
import org.springframework.context.annotation.Bean
8+
import org.springframework.context.annotation.Configuration
9+
10+
@Configuration
11+
class BackupsConfig {
12+
13+
@ConditionalOnProperty("simpleaccounting.backup.dropbox.active", havingValue = "true")
14+
@Configuration
15+
class DropboxBackupConfig {
16+
@Bean
17+
fun dropboxBackupProvider(backupProperties: BackupProperties) = DropboxBackupProvider(backupProperties)
18+
}
19+
20+
@ConditionalOnMissingBean(BackupProvider::class)
21+
@Configuration
22+
class NoOpBackupConfig {
23+
@Bean
24+
fun noOpBackupProvider() = NoOpBackupProvider()
25+
}
26+
27+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package io.orangebuffalo.simpleaccounting.services.integration.backups
2+
3+
import io.orangebuffalo.simpleaccounting.services.integration.withDbContext
4+
import kotlinx.coroutines.runBlocking
5+
import mu.KotlinLogging
6+
import org.springframework.jdbc.core.JdbcTemplate
7+
import org.springframework.scheduling.annotation.Scheduled
8+
import org.springframework.stereotype.Service
9+
import java.nio.file.Files
10+
import java.nio.file.Path
11+
import java.nio.file.Paths
12+
import java.time.LocalDateTime
13+
import java.time.format.DateTimeFormatter
14+
import java.util.concurrent.TimeUnit
15+
16+
private val logger = KotlinLogging.logger {}
17+
private val fileNameDateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm")
18+
19+
/**
20+
* Performs system data backup.
21+
*/
22+
@Service
23+
class SystemDataBackupService(
24+
private val jdbcTemplate: JdbcTemplate,
25+
private val backupProvider: BackupProvider,
26+
private val backupProperties: BackupProperties,
27+
) {
28+
29+
@Scheduled(fixedDelayString = "\${simpleaccounting.backup.scheduling-delay-in-hours}", timeUnit = TimeUnit.HOURS)
30+
fun executeBackup() = runBlocking {
31+
if (!backupProperties.enabled) {
32+
logger.info { "Backup is disabled" }
33+
return@runBlocking
34+
}
35+
36+
logger.info { "Starting system data backup" }
37+
38+
val backupFile = getBackupFile()
39+
40+
try {
41+
withDbContext {
42+
jdbcTemplate.execute("script drop to '${backupFile.toAbsolutePath()}' compression zip")
43+
}
44+
logger.debug { "Database saved to $backupFile" }
45+
46+
backupProvider.acceptBackup(backupFile)
47+
logger.debug { "Backup file $backupFile accepted by backup provider" }
48+
} finally {
49+
Files.delete(backupFile)
50+
}
51+
52+
logger.info { "System data backup completed" }
53+
}
54+
55+
private fun getBackupFile(): Path {
56+
val currentTime = LocalDateTime.now().format(fileNameDateFormatter)
57+
val fileName = "simple-accounting-backup-$currentTime.zip"
58+
val tempDir = Paths.get(System.getProperty("java.io.tmpdir"))
59+
return tempDir.resolve(fileName)
60+
}
61+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package io.orangebuffalo.simpleaccounting.services.integration.backups.impl
2+
3+
import io.orangebuffalo.simpleaccounting.services.integration.backups.BackupProperties
4+
import io.orangebuffalo.simpleaccounting.services.integration.backups.BackupProvider
5+
import io.orangebuffalo.simpleaccounting.services.integration.thirdparty.DropboxApiClient
6+
import io.orangebuffalo.simpleaccounting.services.integration.thirdparty.FileListFolderEntry
7+
import java.nio.file.Path
8+
import kotlin.io.path.name
9+
10+
private const val BACKUP_FOLDER = "/backups"
11+
12+
class DropboxBackupProvider(
13+
private val backupProperties: BackupProperties
14+
) : BackupProvider {
15+
override suspend fun acceptBackup(backupFile: Path) = withClient { client ->
16+
client.uploadFile(backupFile, "$BACKUP_FOLDER/${backupFile.name}")
17+
val existingBackups = client.listFolder(BACKUP_FOLDER)
18+
.filterIsInstance<FileListFolderEntry>()
19+
.sortedBy { it.clientModified }
20+
if (existingBackups.size > backupProperties.maxBackups) {
21+
val backupsToDelete = existingBackups
22+
.subList(0, existingBackups.size - backupProperties.maxBackups)
23+
.map { it.path }
24+
client.deleteFiles(backupsToDelete)
25+
}
26+
}
27+
28+
private suspend fun withClient(block: suspend (client: DropboxApiClient) -> Unit) {
29+
val accessToken = backupProperties.dropbox.accessToken
30+
val refreshToken = backupProperties.dropbox.refreshToken
31+
val clientId = backupProperties.dropbox.clientId
32+
val clientSecret = backupProperties.dropbox.clientSecret
33+
require(accessToken != null && refreshToken != null && clientId != null && clientSecret != null) {
34+
"Dropbox tokens are not configured"
35+
}
36+
DropboxApiClient(
37+
accessToken = accessToken,
38+
refreshToken = refreshToken,
39+
clientId = clientId,
40+
clientSecret = clientSecret
41+
).use { client ->
42+
block(client)
43+
}
44+
}
45+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package io.orangebuffalo.simpleaccounting.services.integration.backups.impl
2+
3+
import io.orangebuffalo.simpleaccounting.services.integration.backups.BackupProvider
4+
import mu.KotlinLogging
5+
6+
private val logger = KotlinLogging.logger {}
7+
8+
class NoOpBackupProvider : BackupProvider {
9+
override suspend fun acceptBackup(backupFile: java.nio.file.Path) {
10+
logger.info { "Backup file $backupFile is ignored by noop provider" }
11+
}
12+
}

0 commit comments

Comments
 (0)