From 2b35139364bc2d7778f450facab5588f5c844118 Mon Sep 17 00:00:00 2001 From: Tronax Date: Sun, 16 Aug 2026 12:04:28 +0200 Subject: [PATCH] feat: add item notes and duplicate detection across platforms - Add per-item notes editable in the detail and edit screens, synced between desktop and Android - Guard against duplicates by external ID and by type/year/title, mirroring the desktop findDuplicate logic in MediaDao - Wrap multi-step database operations in Room transactions - Disable Android auto backup and remove the destructive Room migration fallback so schema bumps fail loudly instead of wiping data - Bound crypto envelope KDF parameters when reading untrusted headers - Handle malformed server responses in SyncClient instead of crashing - Extend settings, database, and image cache on the desktop side --- android/app/src/main/AndroidManifest.xml | 2 +- .../src/main/java/com/umt/tracker/Graph.kt | 2 +- .../com/umt/tracker/data/LibraryRepository.kt | 69 ++++++++--- .../com/umt/tracker/data/local/MediaDao.kt | 17 +++ .../com/umt/tracker/data/local/UmtDatabase.kt | 5 +- .../umt/tracker/data/sync/CryptoEnvelope.kt | 9 ++ .../com/umt/tracker/data/sync/SyncClient.kt | 6 + .../java/com/umt/tracker/domain/Franchise.kt | 113 ++++++++++++++++++ .../com/umt/tracker/ui/detail/DetailScreen.kt | 89 +++++++++++--- .../umt/tracker/ui/detail/DetailViewModel.kt | 6 + .../com/umt/tracker/ui/edit/EditScreen.kt | 37 +++++- .../com/umt/tracker/ui/edit/EditViewModel.kt | 24 +++- .../umt/tracker/ui/library/LibraryScreen.kt | 57 +++++++++ .../tracker/ui/library/LibraryViewModel.kt | 64 ++++++++-- .../umt/tracker/ui/stats/StatsViewModel.kt | 20 ++-- server/main.go | 20 +++- server/ratelimit.go | 48 ++++++++ src/core/Database.cpp | 103 +++++++++++----- src/core/Database.h | 10 +- src/core/Settings.cpp | 18 +++ src/core/Settings.h | 5 + src/providers/ImageCache.cpp | 42 ++++++- src/providers/ImageCache.h | 5 +- src/sync/CryptoEnvelope.cpp | 12 ++ src/sync/SyncClient.cpp | 14 +-- src/ui/DetailDialog.cpp | 27 ++++- src/ui/EditDialog.cpp | 6 +- src/ui/MainWindow.cpp | 110 +++++++++++++++-- src/ui/MainWindow.h | 4 + src/ui/SettingsDialog.cpp | 17 +++ 30 files changed, 837 insertions(+), 124 deletions(-) create mode 100644 android/app/src/main/java/com/umt/tracker/domain/Franchise.kt create mode 100644 server/ratelimit.go diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1786210..89c24bb 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,7 +5,7 @@ > = dao.observeAll().map { rows -> rows.map { it.toDomain() } } @@ -81,6 +86,8 @@ class LibraryRepository( /** Replaces the item's cover with a remote image URL (from a metadata provider). */ suspend fun setCover(id: Long, coverUrl: String) = updateItem(id) { it.copy(coverUrl = coverUrl) } + suspend fun setNotes(id: Long, notes: String) = updateItem(id) { it.copy(notes = notes) } + suspend fun setStatus(id: Long, status: WatchStatus) = updateItem(id) { it.copy(status = status) } /** Marks one unit (episode/chapter) watched or not, by its (segment, unit) number. */ @@ -108,9 +115,29 @@ class LibraryRepository( }) } - private suspend inline fun updateItem(id: Long, transform: (MediaItem) -> MediaItem) { - val current = getItem(id) ?: return - save(transform(current)) + private suspend fun updateItem(id: Long, transform: (MediaItem) -> MediaItem) { + // One transaction around read + write so concurrent toggles can't + // silently clobber each other's updates. + database.withTransaction { + val current = dao.getById(id)?.toDomain() ?: return@withTransaction + save(transform(current)) + } + } + + /** + * Identifies an existing item the given one would duplicate, mirroring the + * desktop guard: match by (externalSource, externalId) when both are set, + * otherwise by (type, title, year). Returns the duplicate's title, or null. + * The item's own id is excluded so an edit never matches itself. + */ + suspend fun findDuplicate(item: MediaItem): String? = withContext(Dispatchers.IO) { + val dupId = when { + item.externalId.isNotBlank() && item.externalSource.isNotBlank() -> + dao.findDuplicateByExternalId(item.externalSource, item.externalId, item.id) + else -> + dao.findDuplicateByTitle(item.type.storage, item.year, item.title.trim(), item.id) + } + dupId?.let { dao.getTitle(it) } } /** Distinct sorted genres across the whole library, for filter chips. */ @@ -131,17 +158,19 @@ class LibraryRepository( } suspend fun stats(): LibraryStats = withContext(Dispatchers.IO) { - val items = dao.getAll().map { it.toDomain() } - LibraryStats( - total = items.size, - completed = items.count { it.status == WatchStatus.COMPLETED }, - inProgress = items.count { it.status == WatchStatus.IN_PROGRESS }, - planned = items.count { it.status == WatchStatus.PLAN_TO_WATCH }, - favorites = items.count { it.favorite }, - perType = items.groupingBy { it.type }.eachCount(), - ) + statsOf(dao.getAll().map { it.toDomain() }) } + /** Pure computation so UI flows can re-derive stats from observed items. */ + fun statsOf(items: List): LibraryStats = LibraryStats( + total = items.size, + completed = items.count { it.status == WatchStatus.COMPLETED }, + inProgress = items.count { it.status == WatchStatus.IN_PROGRESS }, + planned = items.count { it.status == WatchStatus.PLAN_TO_WATCH }, + favorites = items.count { it.favorite }, + perType = items.groupingBy { it.type }.eachCount(), + ) + /** * Replaces the whole library with the contents of [stream]. Import is * all-or-nothing: a parse error leaves the existing data untouched. @@ -153,20 +182,28 @@ class LibraryRepository( /** * Replaces the whole library with [text] (a JSON [LibraryExport]). Used by - * both the file importer and the sync pull. All-or-nothing. + * both the file importer and the sync pull. All-or-nothing: the wipe and + * the inserts run in one Room transaction. */ suspend fun replaceFromJson(text: String): ImportResult = withContext(Dispatchers.IO) { try { val export = LibraryJson.instance.decodeFromString(LibraryExport.serializer(), text) val entities = export.items.map { it.toDomain().toEntity() } - dao.clear() - dao.upsertAll(entities) + database.withTransaction { + dao.clear() + dao.upsertAll(entities) + } ImportResult.Success(entities.size) } catch (e: Exception) { ImportResult.Failure(e.message ?: "Unbekannter Fehler beim Import") } } + /** Writes the canonical JSON snapshot to [stream] (file export / backup). */ + suspend fun exportTo(stream: OutputStream) = withContext(Dispatchers.IO) { + stream.bufferedWriter().use { it.write(snapshotJson()) } + } + /** Serializes the entire library to the canonical JSON snapshot for sync push. */ suspend fun snapshotJson(): String = withContext(Dispatchers.IO) { val items = dao.getAll().map { it.toDomain().toDto() } diff --git a/android/app/src/main/java/com/umt/tracker/data/local/MediaDao.kt b/android/app/src/main/java/com/umt/tracker/data/local/MediaDao.kt index 9a32ff0..91dc165 100644 --- a/android/app/src/main/java/com/umt/tracker/data/local/MediaDao.kt +++ b/android/app/src/main/java/com/umt/tracker/data/local/MediaDao.kt @@ -40,4 +40,21 @@ interface MediaDao { @Query("DELETE FROM media") suspend fun clear() + + // --- duplicate guard (mirrors the desktop findDuplicate) --- + + @Query( + "SELECT id FROM media WHERE externalSource = :source AND externalId = :extId " + + "AND id <> :selfId LIMIT 1" + ) + suspend fun findDuplicateByExternalId(source: String, extId: String, selfId: Long): Long? + + @Query( + "SELECT id FROM media WHERE type = :type AND year = :year " + + "AND LOWER(TRIM(title)) = LOWER(TRIM(:title)) AND id <> :selfId LIMIT 1" + ) + suspend fun findDuplicateByTitle(type: String, year: Int, title: String, selfId: Long): Long? + + @Query("SELECT title FROM media WHERE id = :id") + suspend fun getTitle(id: Long): String? } diff --git a/android/app/src/main/java/com/umt/tracker/data/local/UmtDatabase.kt b/android/app/src/main/java/com/umt/tracker/data/local/UmtDatabase.kt index 4947a4e..da14350 100644 --- a/android/app/src/main/java/com/umt/tracker/data/local/UmtDatabase.kt +++ b/android/app/src/main/java/com/umt/tracker/data/local/UmtDatabase.kt @@ -19,7 +19,10 @@ abstract class UmtDatabase : RoomDatabase() { context.applicationContext, UmtDatabase::class.java, "umt-library.db" - ).fallbackToDestructiveMigration().build().also { instance = it } + ).build().also { instance = it } + // No fallbackToDestructiveMigration(): a schema bump without a + // Migration must fail loudly instead of silently wiping the + // user's library. } } } diff --git a/android/app/src/main/java/com/umt/tracker/data/sync/CryptoEnvelope.kt b/android/app/src/main/java/com/umt/tracker/data/sync/CryptoEnvelope.kt index 9c11781..6a8e8d2 100644 --- a/android/app/src/main/java/com/umt/tracker/data/sync/CryptoEnvelope.kt +++ b/android/app/src/main/java/com/umt/tracker/data/sync/CryptoEnvelope.kt @@ -50,6 +50,13 @@ object CryptoEnvelope { private const val OPSLIMIT = 2L // crypto_pwhash_OPSLIMIT_INTERACTIVE private const val MEMLIMIT = 67108864L // crypto_pwhash_MEMLIMIT_INTERACTIVE (64 MiB) + // Upper bounds accepted when reading an envelope. The header is untrusted + // input (a malicious server can craft it); unbounded values would let a + // crafted snapshot force huge Argon2 allocations (OOM/crash). Legit + // envelopes carry OPSLIMIT/MEMLIMIT. Must stay in sync with the desktop. + private const val MAX_OPS = 8L + private const val MAX_MEM = 268435456L // 256 MiB + private const val HEADER_BYTES = 4 + 1 + 4 + 4 + SALT_BYTES + NONCE_BYTES private val random = SecureRandom() @@ -86,6 +93,8 @@ object CryptoEnvelope { if (version != VERSION) throw CryptoException("Nicht unterstützte Version: $version") val ops = buf.int.toLong() and 0xFFFFFFFFL val mem = buf.int.toLong() and 0xFFFFFFFFL + if (ops == 0L || mem == 0L || ops > MAX_OPS || mem > MAX_MEM) + throw CryptoException("Ungültige Krypto-Parameter im Datenformat") val salt = ByteArray(SALT_BYTES).also { buf.get(it) } val nonce = ByteArray(NONCE_BYTES).also { buf.get(it) } val cipher = ByteArray(buf.remaining()).also { buf.get(it) } diff --git a/android/app/src/main/java/com/umt/tracker/data/sync/SyncClient.kt b/android/app/src/main/java/com/umt/tracker/data/sync/SyncClient.kt index 0baa59e..677acd0 100644 --- a/android/app/src/main/java/com/umt/tracker/data/sync/SyncClient.kt +++ b/android/app/src/main/java/com/umt/tracker/data/sync/SyncClient.kt @@ -68,6 +68,10 @@ class SyncClient( SyncResult.Error(e.message ?: "Entschlüsselung fehlgeschlagen.") } catch (e: IOException) { SyncResult.Error("Netzwerkfehler: ${e.message}") + } catch (e: Exception) { + // Malformed server responses (bad JSON, bad base64, ...) must never + // crash the app — surface them like any other sync error instead. + SyncResult.Error("Antwort des Servers war ungültig: ${e.message}") } } @@ -111,6 +115,8 @@ class SyncClient( SyncResult.Error(e.message ?: "Verschlüsselung fehlgeschlagen.") } catch (e: IOException) { SyncResult.Error("Netzwerkfehler: ${e.message}") + } catch (e: Exception) { + SyncResult.Error("Antwort des Servers war ungültig: ${e.message}") } } diff --git a/android/app/src/main/java/com/umt/tracker/domain/Franchise.kt b/android/app/src/main/java/com/umt/tracker/domain/Franchise.kt new file mode 100644 index 0000000..321ba48 --- /dev/null +++ b/android/app/src/main/java/com/umt/tracker/domain/Franchise.kt @@ -0,0 +1,113 @@ +package com.umt.tracker.domain + +/** + * Franchise auto-detection, ported 1:1 from the desktop app (MediaItem.h) so + * both platforms group titles identically: a subtitle after a colon/dash, a + * trailing "(year)" and a trailing sequel marker (number or roman numeral) are + * dropped, and titles sharing a longest common word prefix collapse onto one + * franchise label. An explicit franchise on the item always wins. + */ +object Franchise { + + private val SEPARATORS = listOf(": ", " - ", " – ", " — ") + private val TAIL_NUMERAL = Regex("\\s+(?:[0-9]{1,3}|[IVXLCDM]+)$") + private val NON_WORD = Regex("[^\\p{L}\\p{N}]+") + + private val STOP_WORDS = setOf( + "the", "a", "an", "and", "of", "to", "in", "on", "for", + "und", "der", "die", "das", "den", "dem", "ein", "eine", + "le", "la", "les", "el", "il", "no", "wa", + ) + + /** Normalized base name of a title (drops subtitle/year/sequel marker). */ + fun keyFromTitle(title: String): String { + var t = title.trim() + for (sep in SEPARATORS) { + val idx = t.indexOf(sep) + if (idx > 0) { t = t.substring(0, idx); break } + } + val paren = t.indexOf('(') + if (paren > 0) t = t.substring(0, paren) + t = t.trim() + t = TAIL_NUMERAL.replace(t, "") + return t.trim() + } + + /** + * The franchise an item belongs to: an explicit value always wins, otherwise + * the auto-detected key of its title (optionally resolved through a + * precomputed [clusterTitles] map). + */ + fun effective(item: MediaItem, autoMap: Map = emptyMap()): String { + item.franchise.trim().takeIf { it.isNotEmpty() }?.let { return it } + return autoMap[item.title] ?: keyFromTitle(item.title) + } + + /** + * Groups titles into franchises by their longest shared word prefix (used by + * at least two titles). Returns a map: original title -> franchise label. + */ + fun clusterTitles(titlesIn: List): Map { + // De-duplicate while preserving order. + val list = LinkedHashSet() + for (t in titlesIn) if (t.isNotBlank()) list.add(t) + val n = list.size + val titles = list.toList() + + val orig = ArrayList>(n) + val norm = ArrayList>(n) + for (i in 0 until n) { + val base = keyFromTitle(titles[i]) + val words = base.split(NON_WORD).filter { it.isNotEmpty() } + orig.add(words) + norm.add(words.map { it.lowercase() }) + } + + // Count how many titles share each word-prefix. + val prefixCount = HashMap() + for (words in norm) { + val key = StringBuilder() + for (w in words) { + if (key.isNotEmpty()) key.append(' ') + key.append(w) + val k = key.toString() + prefixCount[k] = (prefixCount[k] ?: 0) + 1 + } + } + + val labelForKey = HashMap() + val result = HashMap() + for (i in 0 until n) { + // Longest prefix shared by at least two titles. + var bestL = 0 + val key = StringBuilder() + for (L in norm[i].indices) { + if (L > 0) key.append(' ') + key.append(norm[i][L]) + if ((prefixCount[key.toString()] ?: 0) >= 2) bestL = L + 1 + } + if (bestL == 0) { + result[titles[i]] = keyFromTitle(titles[i]) + continue + } + // Drop trailing stop words from the shared prefix. + var len = bestL + while (len > 1 && norm[i][len - 1] in STOP_WORDS) --len + val hasContent = (0 until len).any { norm[i][it] !in STOP_WORDS } + if (!hasContent) { + result[titles[i]] = keyFromTitle(titles[i]) + continue + } + val nkey = StringBuilder() + val disp = StringBuilder() + for (j in 0 until len) { + if (j > 0) { nkey.append(' '); disp.append(' ') } + nkey.append(norm[i][j]) + disp.append(orig[i][j]) + } + if (nkey.toString() !in labelForKey) labelForKey[nkey.toString()] = disp.toString() + result[titles[i]] = labelForKey[nkey.toString()]!! + } + return result + } +} diff --git a/android/app/src/main/java/com/umt/tracker/ui/detail/DetailScreen.kt b/android/app/src/main/java/com/umt/tracker/ui/detail/DetailScreen.kt index d9d7c19..f4d1f1e 100644 --- a/android/app/src/main/java/com/umt/tracker/ui/detail/DetailScreen.kt +++ b/android/app/src/main/java/com/umt/tracker/ui/detail/DetailScreen.kt @@ -1,5 +1,9 @@ package com.umt.tracker.ui.detail +import android.content.Context +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -26,6 +30,7 @@ import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.PhotoLibrary import androidx.compose.material.icons.filled.RadioButtonUnchecked import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api @@ -33,12 +38,14 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf @@ -49,6 +56,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -66,6 +74,9 @@ import com.umt.tracker.domain.unitLabel import com.umt.tracker.ui.components.LabeledDropdown import com.umt.tracker.ui.components.StarRatingInput import com.umt.tracker.ui.components.TagChip +import kotlinx.coroutines.delay +import java.io.File +import java.util.UUID @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -79,6 +90,17 @@ fun DetailScreen( ) { val item by viewModel.item.collectAsStateWithLifecycle() var confirmDelete by remember { mutableStateOf(false) } + val context = LocalContext.current + + // Gallery picker for the cover (parity with the desktop "Datei…" button). + val galleryPicker = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri -> + if (uri != null) { + val path = copyCoverToFiles(context, uri) + if (path != null) viewModel.setCover(path) + } + } Scaffold( topBar = { @@ -122,6 +144,7 @@ fun DetailScreen( item = current, viewModel = viewModel, onChangeCover = { onChangeCover(current.type, current.title) }, + onPickCoverFromGallery = { galleryPicker.launch("image/*") }, modifier = Modifier.padding(padding), ) } @@ -150,6 +173,7 @@ private fun DetailContent( item: MediaItem, viewModel: DetailViewModel, onChangeCover: () -> Unit, + onPickCoverFromGallery: () -> Unit, modifier: Modifier = Modifier, ) { // Seasons/volumes start collapsed for overview, mirroring the desktop app. @@ -195,6 +219,14 @@ private fun DetailContent( ) Text("Cover ändern", modifier = Modifier.padding(start = 6.dp)) } + TextButton(onClick = onPickCoverFromGallery) { + Icon( + Icons.Filled.PhotoLibrary, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Text("Aus Galerie", modifier = Modifier.padding(start = 6.dp)) + } } Column(modifier = Modifier.padding(start = 16.dp)) { Text( @@ -301,26 +333,55 @@ private fun DetailContent( } } - if (item.notes.isNotBlank()) { - item { - Column { - Text( - text = "Notizen", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = item.notes, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) + item { + // Editable notes, saved debounced like the desktop dialog. + Column { + Text( + text = "Notizen", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + var notes by remember(item.id) { mutableStateOf(item.notes) } + var initialized by remember(item.id) { mutableStateOf(false) } + LaunchedEffect(notes) { + if (!initialized) { + initialized = true + return@LaunchedEffect + } + delay(600) + viewModel.setNotes(notes) } + OutlinedTextField( + value = notes, + onValueChange = { notes = it }, + placeholder = { Text("Persönliche Notizen…") }, + minLines = 2, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + ) } } } } +/** + * Copies a picked gallery image into the app's private covers dir and returns a + * file:// URI Coil can render, or null if the copy failed. + */ +private fun copyCoverToFiles(context: Context, uri: Uri): String? = runCatching { + val dir = File(context.filesDir, "covers").apply { mkdirs() } + val ext = context.contentResolver.getType(uri) + ?.substringAfterLast('/') + ?.takeIf { it.length in 2..5 } + ?: "jpg" + val dest = File(dir, "${UUID.randomUUID()}.$ext") + context.contentResolver.openInputStream(uri)?.use { input -> + dest.outputStream().use { output -> input.copyTo(output) } + } ?: return null + "file://${dest.absolutePath}" +}.getOrNull() + @Composable private fun SegmentRow( segment: Segment, diff --git a/android/app/src/main/java/com/umt/tracker/ui/detail/DetailViewModel.kt b/android/app/src/main/java/com/umt/tracker/ui/detail/DetailViewModel.kt index 0a08aae..c0d83c8 100644 --- a/android/app/src/main/java/com/umt/tracker/ui/detail/DetailViewModel.kt +++ b/android/app/src/main/java/com/umt/tracker/ui/detail/DetailViewModel.kt @@ -28,8 +28,14 @@ class DetailViewModel( fun setStatus(status: WatchStatus) = launch { repo.setStatus(itemId, status) } + /** Persists edited free-text notes (detail screen editor). */ + fun setNotes(notes: String) = launch { repo.setNotes(itemId, notes) } + fun setRating(rating: Int) = launch { repo.setRating(itemId, rating) } + /** Sets the cover to a URL or local file:// path (gallery pick). */ + fun setCover(coverUrl: String) = launch { repo.setCover(itemId, coverUrl) } + fun setUnitWatched(segmentNumber: Int, unitNumber: Int, watched: Boolean) = launch { repo.setUnitWatched(itemId, segmentNumber, unitNumber, watched) } diff --git a/android/app/src/main/java/com/umt/tracker/ui/edit/EditScreen.kt b/android/app/src/main/java/com/umt/tracker/ui/edit/EditScreen.kt index f5f5879..42f68ad 100644 --- a/android/app/src/main/java/com/umt/tracker/ui/edit/EditScreen.kt +++ b/android/app/src/main/java/com/umt/tracker/ui/edit/EditScreen.kt @@ -30,7 +30,10 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType @@ -58,6 +61,7 @@ fun EditScreen( ) { val form by viewModel.form.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() + var saveError by remember { mutableStateOf(null) } Scaffold( topBar = { @@ -70,7 +74,17 @@ fun EditScreen( }, actions = { TextButton(onClick = { - scope.launch { viewModel.save()?.let(onSaved) } + scope.launch { + when (val r = viewModel.save()) { + is EditViewModel.SaveResult.Saved -> onSaved(r.id) + is EditViewModel.SaveResult.EmptyTitle -> + saveError = "Bitte einen Titel angeben." + is EditViewModel.SaveResult.Duplicate -> + saveError = "„${r.existingTitle}“ ist bereits in deiner " + + "Bibliothek vorhanden. Es wurde nichts hinzugefügt, damit " + + "dein bestehender Fortschritt nicht überschrieben wird." + } + } }) { Text("Speichern") } }, colors = TopAppBarDefaults.topAppBarColors( @@ -88,6 +102,13 @@ fun EditScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + saveError?.let { err -> + Text( + text = err, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + } LabeledDropdown( label = "Typ", selected = form.type, @@ -221,7 +242,19 @@ fun EditScreen( } Button( - onClick = { scope.launch { viewModel.save()?.let(onSaved) } }, + onClick = { + scope.launch { + when (val r = viewModel.save()) { + is EditViewModel.SaveResult.Saved -> onSaved(r.id) + is EditViewModel.SaveResult.EmptyTitle -> + saveError = "Bitte einen Titel angeben." + is EditViewModel.SaveResult.Duplicate -> + saveError = "„${r.existingTitle}“ ist bereits in deiner " + + "Bibliothek vorhanden. Es wurde nichts hinzugefügt, damit " + + "dein bestehender Fortschritt nicht überschrieben wird." + } + } + }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp), ) { Text("Speichern") } } diff --git a/android/app/src/main/java/com/umt/tracker/ui/edit/EditViewModel.kt b/android/app/src/main/java/com/umt/tracker/ui/edit/EditViewModel.kt index cc227e8..ba147fd 100644 --- a/android/app/src/main/java/com/umt/tracker/ui/edit/EditViewModel.kt +++ b/android/app/src/main/java/com/umt/tracker/ui/edit/EditViewModel.kt @@ -112,11 +112,27 @@ class EditViewModel( it.copy(customFields = it.customFields.mapIndexed { i, p -> if (i == index) key to value else p }) } - /** Persists the form. Returns the saved item id, or null if the title is empty. */ - suspend fun save(): Long? { + /** + * Outcome of saving: either the persisted id, a rejection because the title + * is empty, or a rejection because the item would duplicate an existing one + * (same guard as the desktop app). + */ + sealed interface SaveResult { + data class Saved(val id: Long) : SaveResult + data object EmptyTitle : SaveResult + data class Duplicate(val existingTitle: String) : SaveResult + } + + suspend fun save(): SaveResult { val s = _form.value - if (s.title.isBlank()) return null - return repo.save(s.toMediaItem()) + if (s.title.isBlank()) return SaveResult.EmptyTitle + val item = s.toMediaItem() + // Only new items are checked; an edit keeps its own id and is therefore + // never flagged (mirrors the desktop behaviour). + if (item.id == 0L) { + repo.findDuplicate(item)?.let { return SaveResult.Duplicate(it) } + } + return SaveResult.Saved(repo.save(item)) } private fun EditFormState.toMediaItem(): MediaItem = MediaItem( diff --git a/android/app/src/main/java/com/umt/tracker/ui/library/LibraryScreen.kt b/android/app/src/main/java/com/umt/tracker/ui/library/LibraryScreen.kt index 4036db3..e60e006 100644 --- a/android/app/src/main/java/com/umt/tracker/ui/library/LibraryScreen.kt +++ b/android/app/src/main/java/com/umt/tracker/ui/library/LibraryScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.BarChart import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.FileUpload import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings @@ -93,6 +94,15 @@ fun LibraryScreen( } } + // System file picker for the JSON export target. + val exportPicker = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { uri -> + if (uri != null) { + viewModel.export(uri) { context.contentResolver.openOutputStream(it) } + } + } + LaunchedEffect(message) { message?.let { snackbarHostState.showSnackbar(it) @@ -131,6 +141,9 @@ fun LibraryScreen( IconButton(onClick = { picker.launch(arrayOf("application/json", "*/*")) }) { Icon(Icons.Filled.FileDownload, contentDescription = "Bibliothek importieren") } + IconButton(onClick = { exportPicker.launch("media-library.json") }) { + Icon(Icons.Filled.FileUpload, contentDescription = "Bibliothek exportieren") + } IconButton(onClick = onOpenSettings) { Icon(Icons.Filled.Settings, contentDescription = "Einstellungen") } @@ -188,6 +201,7 @@ fun LibraryScreen( FilterSheet( filter = state.filter, allGenres = state.allGenres, + allTags = state.allTags, allFranchises = state.allFranchises, onChange = viewModel::updateFilter, onClear = viewModel::clearFilters, @@ -252,6 +266,7 @@ private fun EmptyState(onImport: () -> Unit) { private fun FilterSheet( filter: LibraryFilter, allGenres: List, + allTags: List, allFranchises: List, onChange: ((LibraryFilter) -> LibraryFilter) -> Unit, onClear: () -> Unit, @@ -333,6 +348,30 @@ private fun FilterSheet( } } + SectionTitle( + "Jahr: ${filter.yearFrom.takeIf { it > 0 } ?: "…"} – ${filter.yearTo.takeIf { it > 0 } ?: "…"}" + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = filter.yearFrom.takeIf { it > 0 }?.toString() ?: "", + onValueChange = { v -> + onChange { it.copy(yearFrom = v.filter(Char::isDigit).take(4).toIntOrNull() ?: 0) } + }, + label = { Text("von") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + OutlinedTextField( + value = filter.yearTo.takeIf { it > 0 }?.toString() ?: "", + onValueChange = { v -> + onChange { it.copy(yearTo = v.filter(Char::isDigit).take(4).toIntOrNull() ?: 0) } + }, + label = { Text("bis") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + } + if (allGenres.isNotEmpty()) { SectionTitle("Genres") FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -351,6 +390,24 @@ private fun FilterSheet( } } + if (allTags.isNotEmpty()) { + SectionTitle("Tags") + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + allTags.forEach { t -> + FilterChip( + selected = t in filter.tags, + onClick = { + onChange { + val next = if (t in it.tags) it.tags - t else it.tags + t + it.copy(tags = next) + } + }, + label = { Text(t) }, + ) + } + } + } + if (allFranchises.isNotEmpty()) { SectionTitle("Reihe / Franchise") FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { diff --git a/android/app/src/main/java/com/umt/tracker/ui/library/LibraryViewModel.kt b/android/app/src/main/java/com/umt/tracker/ui/library/LibraryViewModel.kt index 0ec334c..d14b08e 100644 --- a/android/app/src/main/java/com/umt/tracker/ui/library/LibraryViewModel.kt +++ b/android/app/src/main/java/com/umt/tracker/ui/library/LibraryViewModel.kt @@ -10,6 +10,7 @@ import com.umt.tracker.data.LibraryRepository import com.umt.tracker.data.sync.SyncClient import com.umt.tracker.data.sync.SyncResult import com.umt.tracker.data.sync.SyncSettings +import com.umt.tracker.domain.Franchise import com.umt.tracker.domain.MediaItem import com.umt.tracker.domain.MediaType import com.umt.tracker.domain.WatchStatus @@ -34,8 +35,11 @@ data class LibraryFilter( val status: WatchStatus? = null, val favoritesOnly: Boolean = false, val genres: Set = emptySet(), + val tags: Set = emptySet(), val franchise: String? = null, val minRating: Int = 0, + val yearFrom: Int = 0, + val yearTo: Int = 0, val sortMode: SortMode = SortMode.DATE_ADDED, val sortDescending: Boolean = true, ) { @@ -44,8 +48,11 @@ data class LibraryFilter( get() = (if (status != null) 1 else 0) + (if (favoritesOnly) 1 else 0) + genres.size + + tags.size + (if (franchise != null) 1 else 0) + - (if (minRating > 0) 1 else 0) + (if (minRating > 0) 1 else 0) + + (if (yearFrom > 0) 1 else 0) + + (if (yearTo > 0) 1 else 0) } data class LibraryUiState( @@ -54,6 +61,7 @@ data class LibraryUiState( val typeFilter: MediaType? = null, val filter: LibraryFilter = LibraryFilter(), val allGenres: List = emptyList(), + val allTags: List = emptyList(), val allFranchises: List = emptyList(), val loading: Boolean = true, val syncEnabled: Boolean = false, @@ -74,12 +82,14 @@ class LibraryViewModel( val uiState: StateFlow = combine(repo.observeLibrary(), query, typeFilter, filter) { items, q, type, f -> + val autoMap = Franchise.clusterTitles(items.map { it.title }) val filtered = items - .filter { it.matches(q, type, f) } + .filter { it.matches(q, type, f, autoMap) } .sortedWith(f.comparator()) // Facets reflect the whole library, not the filtered view. val genres = items.flatMap { it.genres }.distinct().sorted() - val franchises = items.map { it.franchise } + val tags = items.flatMap { it.tags }.distinct().sorted() + val franchises = items.map { Franchise.effective(it, autoMap) } .filter { it.isNotBlank() } .groupingBy { it }.eachCount() .filterValues { it >= 2 }.keys.sorted() @@ -89,6 +99,7 @@ class LibraryViewModel( typeFilter = type, filter = f, allGenres = genres, + allTags = tags, allFranchises = franchises, loading = false, syncEnabled = syncSettings.isCloud && syncSettings.isConfigured, @@ -99,7 +110,12 @@ class LibraryViewModel( initialValue = LibraryUiState(), ) - private fun MediaItem.matches(q: String, type: MediaType?, f: LibraryFilter): Boolean { + private fun MediaItem.matches( + q: String, + type: MediaType?, + f: LibraryFilter, + autoMap: Map, + ): Boolean { if (type != null && this.type != type) return false if (q.isNotBlank() && !title.contains(q, true) && !originalTitle.contains(q, true) && !overview.contains(q, true) @@ -108,7 +124,10 @@ class LibraryViewModel( if (f.favoritesOnly && !favorite) return false if (f.minRating > 0 && rating < f.minRating) return false if (f.genres.isNotEmpty() && !f.genres.all { it in genres }) return false - if (f.franchise != null && franchise != f.franchise) return false + if (f.tags.isNotEmpty() && !f.tags.all { it in tags }) return false + if (f.yearFrom > 0 && year != 0 && year < f.yearFrom) return false + if (f.yearTo > 0 && year != 0 && year > f.yearTo) return false + if (f.franchise != null && Franchise.effective(this, autoMap) != f.franchise) return false return true } @@ -156,13 +175,38 @@ class LibraryViewModel( } } - /** One-tap pull from the server (when cloud sync is configured). */ + /** Writes the whole library as JSON to the user-picked document. */ + fun export(uri: Uri, openStream: (Uri) -> java.io.OutputStream?) { + viewModelScope.launch { + val stream = openStream(uri) + if (stream == null) { + messages.value = "Datei konnte nicht geöffnet werden." + return@launch + } + runCatching { stream.use { repo.exportTo(it) } } + .onSuccess { messages.value = "Bibliothek exportiert." } + .onFailure { messages.value = "Export fehlgeschlagen: ${it.message}" } + } + } + + /** + * One-tap sync like the desktop: push local changes first, then pull the + * canonical server state back down. A push conflict is surfaced for the + * settings screen, where it can be resolved (pull server vs. overwrite). + */ fun syncNow() { viewModelScope.launch { - messages.value = when (val r = syncClient.pull()) { - is SyncResult.Success -> r.message - is SyncResult.Conflict -> "Konflikt – bitte in den Einstellungen auflösen." - is SyncResult.Error -> r.message + messages.value = when (val pushed = syncClient.push()) { + is SyncResult.Conflict -> + "Konflikt: Der Server hat eine neuere Version (Revision " + + "${pushed.serverRevision}). Bitte in den Einstellungen auflösen." + is SyncResult.Error -> pushed.message + is SyncResult.Success -> when (val pulled = syncClient.pull()) { + is SyncResult.Success -> "Bibliothek synchronisiert." + is SyncResult.Error -> pulled.message + is SyncResult.Conflict -> + "Konflikt – bitte in den Einstellungen auflösen." + } } } } diff --git a/android/app/src/main/java/com/umt/tracker/ui/stats/StatsViewModel.kt b/android/app/src/main/java/com/umt/tracker/ui/stats/StatsViewModel.kt index b47ee58..e7d1a0e 100644 --- a/android/app/src/main/java/com/umt/tracker/ui/stats/StatsViewModel.kt +++ b/android/app/src/main/java/com/umt/tracker/ui/stats/StatsViewModel.kt @@ -6,19 +6,21 @@ import androidx.lifecycle.viewModelScope import com.umt.tracker.Graph import com.umt.tracker.data.LibraryRepository import com.umt.tracker.data.LibraryStats -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn class StatsViewModel(private val repo: LibraryRepository) : ViewModel() { - private val _stats = MutableStateFlow(null) - val stats: StateFlow = _stats.asStateFlow() - - init { - viewModelScope.launch { _stats.value = repo.stats() } - } + /** Recomputed whenever the library changes, so stats never go stale. */ + val stats: StateFlow = repo.observeLibrary() + .map { repo.statsOf(it) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = null, + ) companion object { val Factory = object : ViewModelProvider.Factory { diff --git a/server/main.go b/server/main.go index fdbbcc6..7f636a6 100644 --- a/server/main.go +++ b/server/main.go @@ -28,6 +28,7 @@ type server struct { oauth oauth2.Config client *http.Client store *store // nil when DATABASE_URL is unset (sync disabled) + tmdbLim *rateLimiter } func main() { @@ -54,6 +55,8 @@ func main() { Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, }, client: &http.Client{Timeout: 15 * time.Second}, + // Generous per-user cap on proxied TMDB calls (shared API key). + tmdbLim: newRateLimiter(120, time.Minute), } if cfg.DatabaseURL != "" { @@ -184,7 +187,9 @@ func (s *server) handleCallback(w http.ResponseWriter, r *http.Request) { oauthToken, err := s.oauth.Exchange(ctx, r.URL.Query().Get("code"), oauth2.VerifierOption(verifierCookie.Value)) if err != nil { - http.Error(w, "Token-Austausch fehlgeschlagen: "+err.Error(), http.StatusBadGateway) + // Log the details server-side; don't leak upstream internals to clients. + log.Printf("oauth exchange failed: %v", err) + http.Error(w, "Token-Austausch fehlgeschlagen", http.StatusBadGateway) return } @@ -195,7 +200,8 @@ func (s *server) handleCallback(w http.ResponseWriter, r *http.Request) { } idToken, err := s.verifier.Verify(ctx, rawIDToken) if err != nil { - http.Error(w, "id_token-Prüfung fehlgeschlagen: "+err.Error(), http.StatusUnauthorized) + log.Printf("id_token verification failed: %v", err) + http.Error(w, "id_token-Prüfung fehlgeschlagen", http.StatusUnauthorized) return } @@ -293,10 +299,15 @@ func (s *server) handleProxy(w http.ResponseWriter, r *http.Request) { } bearer := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer")) - if _, err := verifyToken(s.cfg.SessionKey, bearer); err != nil { + claims, err := verifyToken(s.cfg.SessionKey, bearer) + if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } + if !s.tmdbLim.allow(claims.Subject) { + http.Error(w, "rate limit exceeded, try again later", http.StatusTooManyRequests) + return + } // Build upstream URL: /3/ -> /, keep the query, force api_key. upstreamPath := strings.TrimPrefix(r.URL.Path, "/3") @@ -319,7 +330,8 @@ func (s *server) handleProxy(w http.ResponseWriter, r *http.Request) { resp, err := s.client.Do(req) if err != nil { - http.Error(w, "upstream error: "+err.Error(), http.StatusBadGateway) + log.Printf("tmdb upstream error for %s: %v", r.URL.Path, err) + http.Error(w, "upstream error", http.StatusBadGateway) return } defer resp.Body.Close() diff --git a/server/ratelimit.go b/server/ratelimit.go new file mode 100644 index 0000000..815f0e8 --- /dev/null +++ b/server/ratelimit.go @@ -0,0 +1,48 @@ +package main + +import ( + "sync" + "time" +) + +// rateLimiter is a minimal in-memory fixed-window limiter keyed by string +// (bearer-token subject for the TMDB proxy). It blunts runaway clients abusing +// the shared API key; it is not a full DoS defense. +type rateLimiter struct { + mu sync.Mutex + limit int + window time.Duration + counts map[string]rateEntry +} + +type rateEntry struct { + windowStart time.Time + count int +} + +func newRateLimiter(limit int, per time.Duration) *rateLimiter { + return &rateLimiter{limit: limit, window: per, counts: map[string]rateEntry{}} +} + +func (r *rateLimiter) allow(key string) bool { + r.mu.Lock() + defer r.mu.Unlock() + + now := time.Now() + e, ok := r.counts[key] + if !ok || now.Sub(e.windowStart) >= r.window { + // Occasionally garbage-collect stale keys so the map stays bounded. + if len(r.counts) > 10_000 { + for k, v := range r.counts { + if now.Sub(v.windowStart) >= r.window { + delete(r.counts, k) + } + } + } + r.counts[key] = rateEntry{windowStart: now, count: 1} + return true + } + e.count++ + r.counts[key] = e + return e.count <= r.limit +} diff --git a/src/core/Database.cpp b/src/core/Database.cpp index 1d8ead0..cfbedde 100644 --- a/src/core/Database.cpp +++ b/src/core/Database.cpp @@ -51,7 +51,10 @@ bool Database::open(const QString &path) { } exec(QStringLiteral("PRAGMA foreign_keys = ON;")); exec(QStringLiteral("PRAGMA journal_mode = WAL;")); - initSchema(); + if (!initSchema()) { + m_lastError = QStringLiteral("Datenbank-Schema konnte nicht initialisiert werden"); + return false; + } return true; } @@ -66,8 +69,8 @@ bool Database::exec(const QString &sql) { return true; } -void Database::initSchema() { - exec(QStringLiteral( +bool Database::initSchema() { + if (!exec(QStringLiteral( "CREATE TABLE IF NOT EXISTS media (" " id INTEGER PRIMARY KEY AUTOINCREMENT," " type TEXT NOT NULL," @@ -86,7 +89,8 @@ void Database::initSchema() { " external_id TEXT," " external_source TEXT," " franchise TEXT" - ");")); + ");"))) + return false; // Migration: add the franchise column to libraries created before it existed. { @@ -99,20 +103,20 @@ void Database::initSchema() { hasFranchise = true; break; } - if (!hasFranchise) - exec(QStringLiteral("ALTER TABLE media ADD COLUMN franchise TEXT")); + if (!hasFranchise && + !exec(QStringLiteral("ALTER TABLE media ADD COLUMN franchise TEXT"))) + return false; } - exec(QStringLiteral( + return exec(QStringLiteral( "CREATE TABLE IF NOT EXISTS segments (" " id INTEGER PRIMARY KEY AUTOINCREMENT," " media_id INTEGER NOT NULL," " number INTEGER NOT NULL DEFAULT 0," " title TEXT," " FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE" - ");")); - - exec(QStringLiteral( + ");")) + && exec(QStringLiteral( "CREATE TABLE IF NOT EXISTS units (" " id INTEGER PRIMARY KEY AUTOINCREMENT," " segment_id INTEGER NOT NULL," @@ -121,34 +125,30 @@ void Database::initSchema() { " watched INTEGER NOT NULL DEFAULT 0," " watched_date TEXT," " FOREIGN KEY(segment_id) REFERENCES segments(id) ON DELETE CASCADE" - ");")); - - exec(QStringLiteral( + ");")) + && exec(QStringLiteral( "CREATE TABLE IF NOT EXISTS genres (" " media_id INTEGER NOT NULL," " genre TEXT NOT NULL," " PRIMARY KEY(media_id, genre)," " FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE" - ");")); - - exec(QStringLiteral( + ");")) + && exec(QStringLiteral( "CREATE TABLE IF NOT EXISTS tags (" " media_id INTEGER NOT NULL," " tag TEXT NOT NULL," " PRIMARY KEY(media_id, tag)," " FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE" - ");")); - - exec(QStringLiteral( + ");")) + && exec(QStringLiteral( "CREATE TABLE IF NOT EXISTS localized_titles (" " media_id INTEGER NOT NULL," " lang TEXT NOT NULL," " title TEXT NOT NULL," " PRIMARY KEY(media_id, lang)," " FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE" - ");")); - - exec(QStringLiteral( + ");")) + && exec(QStringLiteral( "CREATE TABLE IF NOT EXISTS custom_fields (" " media_id INTEGER NOT NULL," " key TEXT NOT NULL," @@ -163,7 +163,25 @@ void Database::initSchema() { // --------------------------------------------------------------------------- bool Database::saveItem(MediaItem &item) { QSqlDatabase db = QSqlDatabase::database(m_connName); - db.transaction(); + if (!db.transaction()) + return saveItemInternal(item); + if (!saveItemInternal(item)) { + db.rollback(); + return false; + } + if (!db.commit()) { + m_lastError = db.lastError().text(); + db.rollback(); + return false; + } + emit changed(); + return true; +} + +// Inserts/updates one item inside the caller's transaction (or autocommit when +// none is active). The caller emits changed() once its transaction is committed. +bool Database::saveItemInternal(MediaItem &item) { + QSqlDatabase db = QSqlDatabase::database(m_connName); QSqlQuery q(db); if (item.id < 0) { @@ -201,7 +219,6 @@ bool Database::saveItem(MediaItem &item) { if (!q.exec()) { m_lastError = q.lastError().text(); - db.rollback(); return false; } if (item.id < 0) @@ -209,12 +226,35 @@ bool Database::saveItem(MediaItem &item) { if (!saveGenresTags(item) || !saveLocalized(item) || !saveCustomFields(item) || !saveSegments(item)) { - db.rollback(); return false; } + return true; +} + +bool Database::replaceAll(const QVector &items, QString *error) { + QSqlDatabase db = QSqlDatabase::database(m_connName); + if (!db.transaction()) { + if (error) *error = QStringLiteral("Datenbank-Transaktion konnte nicht gestartet werden"); + return false; + } + QSqlQuery wipe(db); + if (!wipe.exec(QStringLiteral("DELETE FROM media"))) { + if (error) *error = wipe.lastError().text(); + db.rollback(); + return false; + } + for (MediaItem m : items) { + m.id = -1; // insert as fresh rows + if (!saveItemInternal(m)) { + if (error) *error = m_lastError; + db.rollback(); + return false; + } + } if (!db.commit()) { - m_lastError = db.lastError().text(); + if (error) *error = db.lastError().text(); + db.rollback(); return false; } emit changed(); @@ -487,9 +527,14 @@ QVector Database::queryItems(const FilterCriteria &c) { if (c.yearFrom > 0) { wheres << QStringLiteral("media.year>=?"); binds << c.yearFrom; } if (c.yearTo > 0) { wheres << QStringLiteral("media.year<=?"); binds << c.yearTo; } if (!c.searchText.trimmed().isEmpty()) { - wheres << QStringLiteral("(media.title LIKE ? OR media.original_title LIKE ? " - "OR media.overview LIKE ?)"); - const QString like = QStringLiteral("%%1%").arg(c.searchText.trimmed()); + wheres << QStringLiteral("(media.title LIKE ? ESCAPE '\\' OR media.original_title LIKE ? ESCAPE '\\' " + "OR media.overview LIKE ? ESCAPE '\\')"); + // Escape LIKE wildcards so user input matches literally. + QString like = c.searchText.trimmed(); + like.replace(QLatin1Char('\\'), QStringLiteral("\\\\")) + .replace(QLatin1Char('%'), QStringLiteral("\\%")) + .replace(QLatin1Char('_'), QStringLiteral("\\_")); + like = QStringLiteral("%%1%").arg(like); binds << like << like << like; } diff --git a/src/core/Database.h b/src/core/Database.h index 619559c..e2fbdea 100644 --- a/src/core/Database.h +++ b/src/core/Database.h @@ -53,6 +53,11 @@ public: // Returns items (with full segment trees) matching the criteria. QVector queryItems(const FilterCriteria &c); + // Replaces the whole library with the given items in ONE transaction + // (all-or-nothing). Emits changed() exactly once on success. Used by sync + // pull and JSON import so a failure can never leave a half-replaced library. + bool replaceAll(const QVector &items, QString *error = nullptr); + // Quick toggles avoid re-serializing the whole tree. bool setUnitWatched(int unitId, bool watched); bool setSegmentWatched(int segmentId, bool watched); @@ -82,7 +87,10 @@ signals: private: bool exec(const QString &sql); - void initSchema(); + bool initSchema(); + // Core insert/update used by both saveItem() and replaceAll(); runs inside + // the caller's transaction and never emits changed() itself. + bool saveItemInternal(MediaItem &item); QHash franchiseCounts() const; // Auto-detected franchise label per distinct title (explicit overrides not // applied here; callers layer those on top). diff --git a/src/core/Settings.cpp b/src/core/Settings.cpp index 99cf13c..f091a64 100644 --- a/src/core/Settings.cpp +++ b/src/core/Settings.cpp @@ -1,11 +1,25 @@ #include "core/Settings.h" +#include + namespace umt { AppSettings::AppSettings(QObject *parent) : QObject(parent) , m_s(QStringLiteral("UMT"), QStringLiteral("UltimateMediaTracker")) { + protectSettingsFile(); +} + +void AppSettings::protectSettingsFile() { +#if defined(Q_OS_UNIX) + m_s.sync(); + const QString path = m_s.fileName(); + if (QFile::exists(path)) + QFile::setPermissions(path, QFile::ReadOwner | QFile::WriteOwner); +#else + // Windows: the native format lives in HKCU, which is per-user by default. +#endif } bool AppSettings::darkMode() const { @@ -49,6 +63,7 @@ QString AppSettings::tmdbApiKey() const { } void AppSettings::setTmdbApiKey(const QString &key) { m_s.setValue(QStringLiteral("providers/tmdbKey"), key); + protectSettingsFile(); } QString AppSettings::proxyUrl() const { @@ -63,6 +78,7 @@ QString AppSettings::proxyToken() const { } void AppSettings::setProxyToken(const QString &token) { m_s.setValue(QStringLiteral("providers/proxyToken"), token); + protectSettingsFile(); } QString AppSettings::rawgApiKey() const { @@ -70,6 +86,7 @@ QString AppSettings::rawgApiKey() const { } void AppSettings::setRawgApiKey(const QString &key) { m_s.setValue(QStringLiteral("providers/rawgKey"), key); + protectSettingsFile(); } QString AppSettings::storageMode() const { @@ -85,6 +102,7 @@ QString AppSettings::syncPassphrase() const { } void AppSettings::setSyncPassphrase(const QString &pass) { m_s.setValue(QStringLiteral("sync/passphrase"), pass); + protectSettingsFile(); } qlonglong AppSettings::syncRevision() const { diff --git a/src/core/Settings.h b/src/core/Settings.h index ae97488..02cdc39 100644 --- a/src/core/Settings.h +++ b/src/core/Settings.h @@ -79,6 +79,11 @@ signals: void cardSizeChanged(int px); private: + // QSettings persists with default (often world-readable) permissions; the + // file contains API keys, the proxy token and the sync passphrase, so lock + // it down to the current user after every secret write. + void protectSettingsFile(); + QSettings m_s; }; diff --git a/src/providers/ImageCache.cpp b/src/providers/ImageCache.cpp index 8b0a682..769231c 100644 --- a/src/providers/ImageCache.cpp +++ b/src/providers/ImageCache.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include namespace umt { @@ -27,6 +28,19 @@ QString ImageCache::coversDir() { return base + QStringLiteral("/covers"); } +namespace { +// Covers only ever come from http(s) metadata providers. Restricting the scheme +// keeps URLs injected via imported/synced data (e.g. file://) from turning the +// cache into a local file reader. +constexpr qint64 MAX_IMAGE_BYTES = 15LL * 1024 * 1024; // 15 MiB + +bool isFetchableUrl(const QString &url) { + const QUrl u(url, QUrl::StrictMode); + return u.isValid() && (u.scheme() == QLatin1String("http") || + u.scheme() == QLatin1String("https")); +} +} // namespace + QString ImageCache::cachePathFor(const QString &url) const { const QByteArray hash = QCryptographicHash::hash( url.toUtf8(), QCryptographicHash::Sha1).toHex(); @@ -49,6 +63,16 @@ QPixmap ImageCache::loadLocal(const QString &path) { QPixmap ImageCache::get(const QString &url) { if (url.isEmpty()) return {}; + // Snapshots synced from the Android app can carry local file:// covers + // (picked from the gallery there); render them straight from disk. + if (url.startsWith(QLatin1String("file://"), Qt::CaseInsensitive)) { + const QString local = QUrl(url).toLocalFile(); + QPixmap pm; + if (!local.isEmpty() && pm.load(local)) + return pm; + return {}; + } + if (!isFetchableUrl(url)) return {}; if (m_mem.contains(url)) return m_mem.value(url); const QString cp = cachePathFor(url); @@ -72,7 +96,10 @@ QPixmap ImageCache::get(const QString &url) { } void ImageCache::downloadToLibrary(const QString &url) { - if (url.isEmpty()) return; + if (url.isEmpty() || !isFetchableUrl(url)) { + emit failed(url, QStringLiteral("Ungültige Cover-URL")); + return; + } QNetworkRequest req{QUrl(url)}; req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); @@ -92,7 +119,18 @@ void ImageCache::onFinished(QNetworkReply *reply) { emit failed(url, reply->errorString()); return; } + // Reject oversized bodies announced upfront (protects the disk cache). + const QVariant len = reply->header(QNetworkRequest::ContentLengthHeader); + if (len.isValid() && len.toLongLong() > MAX_IMAGE_BYTES) { + reply->abort(); + emit failed(url, QStringLiteral("Bild ist zu groß")); + return; + } const QByteArray data = reply->readAll(); + if (data.size() > MAX_IMAGE_BYTES) { + emit failed(url, QStringLiteral("Bild ist zu groß")); + return; + } QPixmap pm; if (!pm.loadFromData(data)) { emit failed(url, QStringLiteral("Bild konnte nicht dekodiert werden")); @@ -115,7 +153,7 @@ void ImageCache::onFinished(QNetworkReply *reply) { if (out.open(QIODevice::WriteOnly)) { out.write(data); out.close(); - emit saved(name); // store library-relative name + emit saved(url, name); // store library-relative name } else { emit failed(url, QStringLiteral("Cover konnte nicht gespeichert werden")); } diff --git a/src/providers/ImageCache.h b/src/providers/ImageCache.h index 56130b1..b8a1ede 100644 --- a/src/providers/ImageCache.h +++ b/src/providers/ImageCache.h @@ -32,7 +32,10 @@ public: signals: void ready(const QString &url, const QPixmap &pixmap); - void saved(const QString &localPath); + // Fires with the source url and the library-relative file name so callers + // can match the download they started (guards against mixed-up covers when + // several downloads finish in any order). + void saved(const QString &url, const QString &localPath); void failed(const QString &url, const QString &error); private slots: diff --git a/src/sync/CryptoEnvelope.cpp b/src/sync/CryptoEnvelope.cpp index ba87f55..11d6bf7 100644 --- a/src/sync/CryptoEnvelope.cpp +++ b/src/sync/CryptoEnvelope.cpp @@ -23,6 +23,14 @@ constexpr int ABYTES = 16; // crypto_aead_xchacha20poly1305_ietf_ABYTES constexpr quint64 OPSLIMIT = 2ULL; // crypto_pwhash_OPSLIMIT_INTERACTIVE constexpr quint64 MEMLIMIT = 67108864ULL; // crypto_pwhash_MEMLIMIT_INTERACTIVE (64 MiB) +// Upper bounds accepted when reading an envelope. The header is untrusted +// input (a malicious server can craft it), so unbounded values would let a +// crafted snapshot force huge Argon2 allocations (DoS). Legit envelopes carry +// OPSLIMIT/MEMLIMIT; the headroom keeps future, slightly stronger defaults +// decryptable. Must stay in sync with the Android client. +constexpr quint64 MAX_OPS = 8ULL; +constexpr quint64 MAX_MEM = 268435456ULL; // 256 MiB + constexpr int HEADER_BYTES = 4 + 1 + 4 + 4 + SALT_BYTES + NONCE_BYTES; void setError(QString *error, const QString &msg) { @@ -124,6 +132,10 @@ QByteArray decrypt(const QByteArray &envelope, const QString &passphrase, memcpy(&memBE, p + off, 4); off += 4; const quint64 ops = qFromBigEndian(opsBE); const quint64 mem = qFromBigEndian(memBE); + if (ops == 0 || mem == 0 || ops > MAX_OPS || mem > MAX_MEM) { + setError(error, QStringLiteral("Ungültige Krypto-Parameter im Datenformat")); + return {}; + } const QByteArray salt = envelope.mid(off, SALT_BYTES); off += SALT_BYTES; const QByteArray nonce = envelope.mid(off, NONCE_BYTES); off += NONCE_BYTES; diff --git a/src/sync/SyncClient.cpp b/src/sync/SyncClient.cpp index fbab5e1..cfa482f 100644 --- a/src/sync/SyncClient.cpp +++ b/src/sync/SyncClient.cpp @@ -41,6 +41,9 @@ SyncClient::HttpResponse SyncClient::request(const QString &method, const QByteArray &body) { HttpResponse out; QNetworkRequest req(QUrl(baseUrl() + path)); + // Bound every request so a dead/unreachable server can never hang the + // (user-triggered) sync forever. + req.setTransferTimeout(30000); req.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("UltimateMediaTracker/1.0")); req.setRawHeader("Authorization", @@ -185,15 +188,8 @@ bool SyncClient::replaceLibrary(const QByteArray &snapshotJson, QString *error) if (!LibrarySerializer::fromJson(snapshotJson, &incoming, error)) return false; - // All-or-nothing replace: wipe the current library, then insert the snapshot. - const QVector existing = m_db->queryItems(FilterCriteria{}); - for (const MediaItem &m : existing) - m_db->deleteItem(m.id); - for (MediaItem m : incoming) { - m.id = -1; - m_db->saveItem(m); - } - return true; + // All-or-nothing replace in a single DB transaction; changed() fires once. + return m_db->replaceAll(incoming, error); } } // namespace umt diff --git a/src/ui/DetailDialog.cpp b/src/ui/DetailDialog.cpp index 83a0daf..8dc3f0f 100644 --- a/src/ui/DetailDialog.cpp +++ b/src/ui/DetailDialog.cpp @@ -375,10 +375,19 @@ void DetailDialog::changeCoverViaSearch() const SearchResult res = dlg.selectedResult(); if (res.coverUrl.isEmpty()) return; - connect(m_cache, &ImageCache::saved, this, [this](const QString &localName){ - m_item.coverPath = localName; - m_item.coverUrl.clear(); - m_db->saveItem(m_item); + const QString wantUrl = res.coverUrl; + const int itemId = m_id; + Database *db = m_db; + connect(m_cache, &ImageCache::saved, this, + [this, db, itemId, wantUrl](const QString &url, const QString &localName){ + if (url != wantUrl) return; // never apply a different download's cover + // Re-read the row so progress changed in the meantime survives the save. + auto fresh = db->loadItem(itemId); + if (!fresh) return; + fresh->coverPath = localName; + fresh->coverUrl.clear(); + db->saveItem(*fresh); + m_item = *fresh; QPixmap pm = ImageCache::loadLocal(localName); m_cover->setPixmap(rounded(pm, 244, 354, 12)); emit itemModified(); @@ -389,8 +398,14 @@ void DetailDialog::changeCoverViaSearch() void DetailDialog::persistNotes() { if (m_notes->toPlainText() != m_item.notes) { - m_item.notes = m_notes->toPlainText(); - m_db->saveItem(m_item); + // Re-read the row first: progress toggles written directly to the DB + // since reload() must survive this full-item save, and m_item's stale + // segment tree would otherwise revert them. + auto fresh = m_db->loadItem(m_id); + if (!fresh) return; + fresh->notes = m_notes->toPlainText(); + m_db->saveItem(*fresh); + m_item.notes = fresh->notes; emit itemModified(); } } diff --git a/src/ui/EditDialog.cpp b/src/ui/EditDialog.cpp index 3588323..6a2686a 100644 --- a/src/ui/EditDialog.cpp +++ b/src/ui/EditDialog.cpp @@ -487,11 +487,15 @@ void EditDialog::accept() if (!m_pendingCoverUrl.isEmpty() && m_settings->autoFetchCovers()) { const int id = m_item.id; Database *db = m_db; + const QString wantUrl = m_pendingCoverUrl; // Bind the context to the long-lived database, NOT to this dialog: // the dialog is destroyed right after accept(), well before the async // download finishes, which previously dropped the cover update. connect(m_cache, &ImageCache::saved, db, - [db, id](const QString &localName){ + [db, id, wantUrl](const QString &url, const QString &localName){ + // Only accept the download this item started — several downloads + // can finish in any order and must not swap covers. + if (url != wantUrl) return; if (auto opt = db->loadItem(id)) { MediaItem mi = *opt; mi.coverPath = localName; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index f212ed3..2eadd68 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -8,6 +8,7 @@ #include "ui/SettingsDialog.h" #include "core/Settings.h" #include "sync/SyncClient.h" +#include "sync/LibrarySerializer.h" #include "providers/ProviderManager.h" #include "providers/ImageCache.h" @@ -27,6 +28,8 @@ #include #include #include +#include +#include namespace umt { @@ -46,6 +49,8 @@ MainWindow::MainWindow(Database *db, AppSettings *settings, ThemeManager *theme, buildUi(); + m_syncClient = new SyncClient(m_db, m_settings, this); + connect(m_db, &Database::changed, this, [this]{ rebuildFilterLists(); }); @@ -156,6 +161,19 @@ void MainWindow::buildUi() connect(settingsBtn, &QPushButton::clicked, this, &MainWindow::openSettings); tb->addWidget(settingsBtn); + auto *menuBtn = new QToolButton(topBar); + menuBtn->setText(QStringLiteral("⋮")); + menuBtn->setObjectName(QStringLiteral("IconButton")); + menuBtn->setFixedSize(40, rowH); + auto *libMenu = new QMenu(menuBtn); + libMenu->addAction(QStringLiteral("Bibliothek exportieren…"), this, + &MainWindow::exportLibrary); + libMenu->addAction(QStringLiteral("Bibliothek importieren…"), this, + &MainWindow::importLibrary); + menuBtn->setMenu(libMenu); + menuBtn->setPopupMode(QToolButton::InstantPopup); + tb->addWidget(menuBtn); + m_syncBtn->setVisible(m_settings->storageMode() == QLatin1String("cloud")); libLayout->addWidget(topBar); @@ -383,8 +401,7 @@ void MainWindow::openSettings() void MainWindow::syncNow() { - SyncClient client(m_db, m_settings, this); - if (!client.isConfigured()) { + if (!m_syncClient->isConfigured()) { QMessageBox::information(this, QStringLiteral("Synchronisieren"), QStringLiteral("Cloud-Sync ist nicht vollständig konfiguriert. " "Bitte in den Einstellungen den Speicherort auf „Cloud-Sync“ " @@ -392,13 +409,22 @@ void MainWindow::syncNow() return; } + // The sync runs a nested event loop; locking the whole window prevents + // re-entrant edits/deletes while the library is being replaced. + QWidget *ui = centralWidget(); + ui->setEnabled(false); m_syncBtn->setEnabled(false); QApplication::setOverrideCursor(Qt::WaitCursor); + auto unlock = [this, ui]() { + QApplication::restoreOverrideCursor(); + ui->setEnabled(true); + m_syncBtn->setEnabled(true); + }; // Upload local changes first; resolve a conflict by asking the user. - SyncClient::Result up = client.push(false); + SyncClient::Result up = m_syncClient->push(false); if (up.status == SyncClient::Status::Conflict) { - QApplication::restoreOverrideCursor(); + unlock(); QMessageBox box(this); box.setIcon(QMessageBox::Question); box.setWindowTitle(QStringLiteral("Sync-Konflikt")); @@ -413,11 +439,12 @@ void MainWindow::syncNow() box.addButton(QStringLiteral("Lokal überschreiben"), QMessageBox::DestructiveRole); box.exec(); const bool loadServer = (box.clickedButton() == loadBtn); + ui->setEnabled(false); + m_syncBtn->setEnabled(false); QApplication::setOverrideCursor(Qt::WaitCursor); if (loadServer) { - const SyncClient::Result pulled = client.pull(); - QApplication::restoreOverrideCursor(); - m_syncBtn->setEnabled(true); + const SyncClient::Result pulled = m_syncClient->pull(); + unlock(); if (pulled.status == SyncClient::Status::Success) { rebuildFilterLists(); refresh(); @@ -428,20 +455,18 @@ void MainWindow::syncNow() } return; } - up = client.push(true); // overwrite + up = m_syncClient->push(true); // overwrite } if (up.status != SyncClient::Status::Success) { - QApplication::restoreOverrideCursor(); - m_syncBtn->setEnabled(true); + unlock(); QMessageBox::warning(this, QStringLiteral("Synchronisieren"), up.message); return; } // Then pull the canonical server state back down so this device matches it. - const SyncClient::Result down = client.pull(); - QApplication::restoreOverrideCursor(); - m_syncBtn->setEnabled(true); + const SyncClient::Result down = m_syncClient->pull(); + unlock(); if (down.status != SyncClient::Status::Success) { QMessageBox::warning(this, QStringLiteral("Synchronisieren"), down.message); return; @@ -452,6 +477,65 @@ void MainWindow::syncNow() QStringLiteral("Bibliothek synchronisiert.")); } +void MainWindow::exportLibrary() +{ + const QString path = QFileDialog::getSaveFileName( + this, QStringLiteral("Bibliothek exportieren"), QString(), + QStringLiteral("JSON (*.json);;Alle Dateien (*)")); + if (path.isEmpty()) return; + + const QByteArray json = LibrarySerializer::toJson(m_db->queryItems(FilterCriteria{})); + QFile f(path); + if (!f.open(QIODevice::WriteOnly)) { + QMessageBox::warning(this, QStringLiteral("Export"), + QStringLiteral("Datei konnte nicht geschrieben werden:\n%1").arg(path)); + return; + } + f.write(json); + f.close(); + QMessageBox::information(this, QStringLiteral("Export"), + QStringLiteral("Bibliothek exportiert nach:\n%1").arg(path)); +} + +void MainWindow::importLibrary() +{ + const QString path = QFileDialog::getOpenFileName( + this, QStringLiteral("Bibliothek importieren"), QString(), + QStringLiteral("JSON (*.json);;Alle Dateien (*)")); + if (path.isEmpty()) return; + + QFile f(path); + if (!f.open(QIODevice::ReadOnly)) { + QMessageBox::warning(this, QStringLiteral("Import"), + QStringLiteral("Datei konnte nicht gelesen werden:\n%1").arg(path)); + return; + } + const QByteArray json = f.readAll(); + f.close(); + + QVector items; + QString err; + if (!LibrarySerializer::fromJson(json, &items, &err)) { + QMessageBox::warning(this, QStringLiteral("Import"), + QStringLiteral("Ungültige Datei:\n%1").arg(err)); + return; + } + if (QMessageBox::question(this, QStringLiteral("Import"), + QStringLiteral("%1 Einträge importieren?\n\n" + "Die aktuelle Bibliothek wird dabei vollständig " + "ersetzt.").arg(items.size())) + != QMessageBox::Yes) + return; + + if (!m_db->replaceAll(items, &err)) { + QMessageBox::critical(this, QStringLiteral("Import"), + QStringLiteral("Import fehlgeschlagen:\n%1").arg(err)); + return; + } + rebuildFilterLists(); + refresh(); +} + void MainWindow::toggleTheme() { m_settings->setDarkMode(!m_settings->darkMode()); diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 7352f9b..6670ca6 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -24,6 +24,7 @@ class ProviderManager; class ImageCache; class FilterPanel; class FlowLayout; +class SyncClient; // Top-level window: sidebar (media-type sections + favorites + stats), // toolbar (search, sort, add, settings, theme) and the card grid. @@ -43,6 +44,8 @@ private slots: void openSettings(); void syncNow(); void toggleTheme(); + void importLibrary(); + void exportLibrary(); private: void buildUi(); @@ -60,6 +63,7 @@ private: ThemeManager *m_theme; ProviderManager *m_providers; ImageCache *m_cache; + SyncClient *m_syncClient = nullptr; // reused for every sync std::optional m_section; // nullopt = "Alle" bool m_favoritesSection = false; diff --git a/src/ui/SettingsDialog.cpp b/src/ui/SettingsDialog.cpp index 6668a89..134dcb6 100644 --- a/src/ui/SettingsDialog.cpp +++ b/src/ui/SettingsDialog.cpp @@ -14,6 +14,7 @@ #include #include #include +#include namespace umt { @@ -211,6 +212,22 @@ SettingsDialog::SettingsDialog(AppSettings *settings, QWidget *parent) connect(m_tmdbMode, &QComboBox::currentIndexChanged, this, &SettingsDialog::updateTmdbModeUi); connect(bb, &QDialogButtonBox::accepted, this, [this]{ + const QString purl = m_proxyUrl->text().trimmed(); + if (purl.startsWith(QStringLiteral("http://"), Qt::CaseInsensitive)) { + const QString host = QUrl(purl).host(); + const bool local = (host == QLatin1String("localhost") || + host == QLatin1String("127.0.0.1") || + host == QLatin1String("::1")); + if (!local && + QMessageBox::warning(this, QStringLiteral("Unverschlüsselte Verbindung"), + QStringLiteral("Die Proxy-URL verwendet http://. Token und " + "Sync-Daten würden unverschlüsselt übertragen " + "und könnten mitgelesen werden.\n\n" + "Wirklich fortfahren?"), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + != QMessageBox::Yes) + return; + } m_settings->setDarkMode(m_theme->currentData().toBool()); m_settings->setPreferredLanguage(m_language->currentData().toString()); m_settings->setTmdbMode(m_tmdbMode->currentData().toString());