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();