diff --git a/README.md b/README.md index f5e8c2f..39be7e9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Regélő – Android alkalmazás (v1.3.2) +# Regélő – Android alkalmazás (v1.3.3) Magyar írói és olvasói platform (Wattpad / Inkitt jellegű), Kotlin + Jetpack Compose. @@ -23,7 +23,7 @@ Google Playre aláírt Android App Bundle-t (AAB) tölts fel, ne fejlesztői deb 4. Készítsd el az AAB-t, ellenőrizd a Play Console belső tesztcsatornáján, majd csak sikeres ellenőrzés után indítsd a szélesebb körű kiadást. 5. Minden új feltöltés előtt növeld az `app/build.gradle.kts` fájlban a `versionCode` értékét. Jelenlegi értéke - `132`; ez azonos vagy kisebb értékkel nem tölthető fel frissítésként. + `133`; ez azonos vagy kisebb értékkel nem tölthető fel frissítésként. ### Parancssoros/CI aláírás Biztonságos build/CI titokként add meg mind a négy környezeti változót: @@ -51,6 +51,7 @@ a szerveroldali hitelesítés és RLS-szabályok adják. Service-role kulcsot ti - A fióktörlés biztonságos útvonalához alkalmazd a `supabase/2026-10-26_app_v1_4_1.sql` migrációt, és telepítsd a `delete-account` Edge Functiont. A mobilkliens nem használ adatbázis-fallbacket, így sikertelen fájltakarítás esetén nem törli a fiókot. +- Az adatvédelmi export kiegészítéséhez alkalmazd a `supabase/2026-10-26_app_v1_4_2.sql` migrációt. - A helyi Room-adatbázis SQLCipherrel titkosított. Meglévő telepítésen az indulási migráció WAL-ellenőrzőpontot készít, külön titkosított példányon konvertál, ellenőrzi, majd atomi fájlcserével vált; hiba esetén az eredeti adatbázis marad érintetlen. A véletlen adatvesztés elkerülésére ne töröld az alkalmazás adatait vagy Keystore-kulcsát. @@ -59,7 +60,9 @@ a szerveroldali hitelesítés és RLS-szabályok adják. Service-role kulcsot ti A `supabase/` mappában lévő SQL fájlok idempotensek (többször is futtathatók) a Supabase SQL Editorban. A kliens frissítésének kiadása előtt futtasd a `supabase/2026-10-26_app_v1_4.sql` migrációt a v1.3 után; az idempotens olvasásbefejezés RPC-t igényli. A hozzá tartozó adatbázis-tesztsor: -`supabase/tests/reading_state_v1_4.sql`. +`supabase/tests/reading_state_v1_4.sql`. A fióktörlés és teljes adatexport javításaihoz ezután futtasd a +`2026-10-26_app_v1_4_1.sql` és `2026-10-26_app_v1_4_2.sql` fájlokat; az RLS-tesztsor ellenőrzi az export +rekordjainak hatókörét és a törlési RPC jogosultságát. ## Mentés Ez a mappa a teljes forráskód. Készíts róla rendszeresen másolatot (pl. OneDrive vagy GitHub privát tároló), @@ -80,6 +83,10 @@ Hivatalos e-mail: regeloapp@gmail.com · Hivatalos profil az appban: @regelohiva keresztül töröl; hiba esetén jelzi, hogy a törlés eredménye nem igazolható, és nem indít fájlokat hátrahagyó adatbázis-fallbacket. A Supabase RPC-jogosultság visszavonva, a Storage-mappák mélységhatárának elérése pedig hibával leállítja a törlést a hiányos sikerjelzés helyett. +- **1.3.3 – Helyi képek és adatportabilitás** – A feltöltésre váró borítóképek AES-GCM-mel, Android Keystore-kulccsal + titkosítva tárolódnak, nem kerülnek biztonsági mentésbe, és kijelentkezéskor/fióktörléskor törlődnek; a korábbi + egyszerű JPEG-fájlok az első feltöltéskor atomi cserével titkosítottra migrálódnak. A GDPR-export már a saját + közösségi jelentéseket és értesítéseket is tartalmazza, más felhasználók ilyen rekordjai nélkül. - **v1.4 backend-kiegészítés** – A könyv befejezésének mentése szerveroldali, idempotens művelet, amely a budapesti naptári évet és az adatbázisban szereplő publikált/befejezett állapotot használja. A rate limit párhuzamos kéréseknél is atomikusan érvényesül. A kliens nem ment tokent titkosítatlanul, diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 005c110..70bde4b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { applicationId = "hu.regelo.app" minSdk = 26 targetSdk = 37 - versionCode = 132 - versionName = "1.3.2" + versionCode = 133 + versionName = "1.3.3" buildConfigField("String", "SUPABASE_URL", "\"https://pqmfezkvaslqedalwwnn.supabase.co\"") // Nyilvános (publishable) kulcs – a sorszintű jogosultságok (RLS) védik az adatokat. diff --git a/app/src/main/java/hu/regelo/app/data/AuthRepository.kt b/app/src/main/java/hu/regelo/app/data/AuthRepository.kt index e069e67..e30e0b6 100644 --- a/app/src/main/java/hu/regelo/app/data/AuthRepository.kt +++ b/app/src/main/java/hu/regelo/app/data/AuthRepository.kt @@ -5,6 +5,7 @@ import hu.regelo.app.util.orReportNull import android.content.Intent import hu.regelo.app.AppGraph import hu.regelo.app.push.PushTokens +import hu.regelo.app.util.Images import io.github.jan.supabase.auth.auth import io.github.jan.supabase.auth.handleDeeplinks import io.github.jan.supabase.auth.providers.builtin.Email @@ -210,6 +211,7 @@ class AuthRepository { } private suspend fun clearLocalData() { + val pendingImagesError = runCatching { Images.clearPending(AppGraph.appContext) }.exceptionOrNull() AppGraph.db.books().clear() AppGraph.db.chapters().clear() AppGraph.db.progress().clear() @@ -226,5 +228,9 @@ class AuthRepository { runCatching { AppGraph.settings.setShowOfficialStrip(true) } AppGraph.writing.resetSyncState() _state.value = AuthState.SignedOut + pendingImagesError?.let { error -> + hu.regelo.app.util.ErrorReporter.report(error, "pending-image-cleanup") + throw IllegalStateException("A helyi függő képeket nem sikerült törölni.", error) + } } } diff --git a/app/src/main/java/hu/regelo/app/data/WritingRepository.kt b/app/src/main/java/hu/regelo/app/data/WritingRepository.kt index 69de23f..be3212c 100644 --- a/app/src/main/java/hu/regelo/app/data/WritingRepository.kt +++ b/app/src/main/java/hu/regelo/app/data/WritingRepository.kt @@ -297,7 +297,7 @@ class WritingRepository { if (pending != null) { val file = File(pending) if (file.exists()) { - val url = AppGraph.storage.uploadJpeg(StorageRepository.Buckets.COVERS, "${b.id}-${nowMs()}.jpg", file.readBytes()) + val url = AppGraph.storage.uploadJpeg(StorageRepository.Buckets.COVERS, "${b.id}-${nowMs()}.jpg", Images.readTemp(file)) book = book.copy(coverUrl = url, pendingCoverPath = null) file.delete() } else book = book.copy(pendingCoverPath = null) diff --git a/app/src/main/java/hu/regelo/app/util/Images.kt b/app/src/main/java/hu/regelo/app/util/Images.kt index 0828704..e68caf0 100644 --- a/app/src/main/java/hu/regelo/app/util/Images.kt +++ b/app/src/main/java/hu/regelo/app/util/Images.kt @@ -6,9 +6,18 @@ import androidx.core.graphics.scale import android.graphics.BitmapFactory import android.graphics.Matrix import android.net.Uri +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.system.Os import androidx.exifinterface.media.ExifInterface import java.io.ByteArrayOutputStream import java.io.File +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt @@ -61,11 +70,72 @@ object Images { } fun saveTemp(context: Context, bytes: ByteArray, name: String): File { + require(name == File(name).name) { "Invalid temporary image name." } val dir = File(context.filesDir, "pending").apply { mkdirs() } val file = File(dir, name) - file.writeBytes(bytes) + file.writeBytes(encryptTemp(bytes)) return file } + fun readTemp(file: File): ByteArray { + val stored = file.readBytes() + if (!stored.startsWith(TEMP_MAGIC)) { + val encryptedFile = File(file.parentFile, "${file.name}.encrypted.tmp") + encryptedFile.writeBytes(encryptTemp(stored)) + try { + Os.rename(encryptedFile.absolutePath, file.absolutePath) + } catch (e: Exception) { + encryptedFile.delete() + throw e + } + return stored + } + check(stored.size > TEMP_MAGIC.size + GCM_IV_SIZE) { "Temporary image is corrupted." } + val iv = stored.copyOfRange(TEMP_MAGIC.size, TEMP_MAGIC.size + GCM_IV_SIZE) + val ciphertext = stored.copyOfRange(TEMP_MAGIC.size + GCM_IV_SIZE, stored.size) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, tempKey(create = false), GCMParameterSpec(128, iv)) + return cipher.doFinal(ciphertext) + } + + fun clearPending(context: Context) { + val dir = File(context.filesDir, "pending") + val deleted = !dir.exists() || dir.deleteRecursively() + val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) } + keyStore.deleteEntry(TEMP_KEY_ALIAS) + check(deleted && !dir.exists()) { "Pending images could not be removed." } + } + + private fun encryptTemp(bytes: ByteArray): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, tempKey(create = true)) + return TEMP_MAGIC + cipher.iv + cipher.doFinal(bytes) + } + + private fun tempKey(create: Boolean): SecretKey { + val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) } + (keyStore.getKey(TEMP_KEY_ALIAS, null) as? SecretKey)?.let { return it } + check(create) { "Temporary image encryption key is unavailable." } + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) + val builder = KeyGenParameterSpec.Builder( + TEMP_KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ).setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setUnlockedDeviceRequired(true) + generator.init(builder.build()) + return generator.generateKey() + } + fun clamp01(v: Float) = min(1f, max(0f, v)) + + private fun ByteArray.startsWith(prefix: ByteArray): Boolean = + size >= prefix.size && prefix.indices.all { this[it] == prefix[it] } + + private const val KEYSTORE = "AndroidKeyStore" + private const val TEMP_KEY_ALIAS = "regelo_pending_image_key" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val GCM_IV_SIZE = 12 + private val TEMP_MAGIC = byteArrayOf(0x52, 0x47, 0x49, 0x01) } diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index bc962ef..3c53e7c 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -1,8 +1,9 @@ - + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index fb449dc..9232861 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,15 +1,17 @@ - + + diff --git a/supabase/2026-10-26_app_v1_4_2.sql b/supabase/2026-10-26_app_v1_4_2.sql new file mode 100644 index 0000000..63f0f70 --- /dev/null +++ b/supabase/2026-10-26_app_v1_4_2.sql @@ -0,0 +1,60 @@ +-- Regélő app v1.4.2 – egészítsük ki a fiók adatexportját a közösségi jelentésekkel és értesítésekkel. +create or replace function public.regelo_export_my_data() +returns jsonb +language plpgsql +security definer +set search_path to 'public' +as $function$ +declare + v_uid uuid := auth.uid(); +begin + if v_uid is null then + raise exception 'Bejelentkezés szükséges.' using errcode = '28000'; + end if; + perform public.regelo_rate_limit('export', 1, interval '1 hour'); + return jsonb_build_object( + 'generated_at', now(), + 'account', (select jsonb_build_object('id', u.id, 'email', u.email, 'created_at', u.created_at) from auth.users u where u.id = v_uid), + 'profile', (select to_jsonb(p) from public.public_profiles p where p.user_id = v_uid), + 'series', coalesce((select jsonb_agg(to_jsonb(s)) from public.series s where s.author_id = v_uid), '[]'), + 'books', coalesce((select jsonb_agg(to_jsonb(b)) from public.books b where b.author_id = v_uid), '[]'), + 'chapters', coalesce((select jsonb_agg(to_jsonb(c)) from public.chapters c join public.books b on b.id = c.book_id + where b.author_id = v_uid), '[]'), + 'announcements', coalesce((select jsonb_agg(to_jsonb(a)) from public.author_announcements a where a.author_id = v_uid), '[]'), + 'reading_progress', coalesce((select jsonb_agg(to_jsonb(r)) from public.reader_progress r where r.user_id = v_uid), '[]'), + 'saved_books', coalesce((select jsonb_agg(to_jsonb(s)) from public.reader_saved_books s where s.user_id = v_uid), '[]'), + 'library', coalesce((select jsonb_agg(to_jsonb(l)) from public.reader_library_items l where l.user_id = v_uid), '[]'), + 'book_likes', coalesce((select jsonb_agg(to_jsonb(l)) from public.book_likes l where l.user_id = v_uid), '[]'), + 'chapter_likes', coalesce((select jsonb_agg(to_jsonb(l)) from public.chapter_likes l where l.user_id = v_uid), '[]'), + 'ratings', coalesce((select jsonb_agg(to_jsonb(r)) from public.book_ratings r where r.user_id = v_uid), '[]'), + 'comments', coalesce((select jsonb_agg(to_jsonb(c)) from public.chapter_comments c where c.author_id = v_uid), '[]'), + 'follows', coalesce((select jsonb_agg(to_jsonb(f)) from public.user_follows f where f.follower_id = v_uid), '[]'), + 'blocked_users', coalesce((select jsonb_agg(to_jsonb(b)) from public.blocked_users b where b.user_id = v_uid), '[]'), + 'messages', coalesce((select jsonb_agg(jsonb_build_object('id', m.id, 'direction', + case when m.sender_id = v_uid then 'sent' else 'received' end, + 'partner_id', case when m.sender_id = v_uid then m.recipient_id else m.sender_id end, + 'body', m.body, 'created_at', m.created_at, 'read_at', m.read_at) order by m.created_at) + from public.community_messages m where m.sender_id = v_uid or m.recipient_id = v_uid), '[]'), + 'circle_memberships', coalesce((select jsonb_agg(to_jsonb(m)) from public.reading_circle_members m where m.user_id = v_uid), '[]'), + 'circle_posts', coalesce((select jsonb_agg(to_jsonb(p)) from public.reading_circle_posts p where p.author_id = v_uid), '[]'), + 'challenge_entries', coalesce((select jsonb_agg(to_jsonb(e)) from public.writing_challenge_entries e where e.author_id = v_uid), '[]'), + 'challenge_votes', coalesce((select jsonb_agg(to_jsonb(v)) from public.writing_challenge_votes v where v.voter_id = v_uid), '[]'), + 'reports_filed', coalesce((select jsonb_agg(jsonb_build_object('target_type', r.target_type, 'reason', r.reason, 'created_at', r.created_at)) + from public.reports r where r.reporter_id = v_uid), '[]'), + 'community_reports_filed', coalesce((select jsonb_agg(jsonb_build_object( + 'id', r.id, 'reporter_id', r.reporter_id, 'comment_id', r.comment_id, + 'reported_user_id', r.reported_user_id, 'reason', r.reason, + 'status', r.status, 'created_at', r.created_at) order by r.created_at) + from public.community_reports r where r.reporter_id = v_uid), '[]'), + 'community_notifications', coalesce((select jsonb_agg(jsonb_build_object( + 'id', n.id, 'recipient_id', n.recipient_id, 'actor_id', n.actor_id, + 'kind', n.kind, 'book_id', n.book_id, 'comment_id', n.comment_id, + 'message_id', n.message_id, 'announcement_id', n.announcement_id, + 'payload', n.payload, 'read_at', n.read_at, + 'created_at', n.created_at) order by n.created_at) + from public.community_notifications n where n.recipient_id = v_uid), '[]'), + 'official_subscriptions', coalesce((select jsonb_agg(to_jsonb(s)) from public.regelo_official_subscriptions s where s.user_id = v_uid), '[]'), + 'push_devices', (select count(*) from public.device_push_tokens t where t.user_id = v_uid) + ); +end +$function$; diff --git a/supabase/tests/rls_battery_v1_3.sql b/supabase/tests/rls_battery_v1_3.sql index 04c268d..76edc73 100644 --- a/supabase/tests/rls_battery_v1_3.sql +++ b/supabase/tests/rls_battery_v1_3.sql @@ -106,6 +106,16 @@ begin insert into t_log(ok, msg) values ((j -> 'profile' ->> 'user_id') = a::text and not exists (select 1 from jsonb_array_elements(j -> 'comments') c where c ->> 'author_id' <> a::text), 'Export: csak a saját adatok, ' || jsonb_array_length(j -> 'comments') || ' komment'); + insert into t_log(ok, msg) values ( + jsonb_array_length(j -> 'community_reports_filed') = + (select count(*) from public.community_reports where reporter_id = a) + and not exists (select 1 from jsonb_array_elements(j -> 'community_reports_filed') r where r ->> 'reporter_id' <> a::text), + 'Export: saját közösségi jelentések, teljes darabszám és nincs más felhasználó adata'); + insert into t_log(ok, msg) values ( + jsonb_array_length(j -> 'community_notifications') = + (select count(*) from public.community_notifications where recipient_id = a) + and not exists (select 1 from jsonb_array_elements(j -> 'community_notifications') n where n ->> 'recipient_id' <> a::text), + 'Export: saját értesítések, teljes darabszám és nincs más felhasználó adata'); begin j := public.regelo_export_my_data();