Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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ó),
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/java/hu/regelo/app/data/AuthRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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)
}
}
}
2 changes: 1 addition & 1 deletion app/src/main/java/hu/regelo/app/data/WritingRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
72 changes: 71 additions & 1 deletion app/src/main/java/hu/regelo/app/util/Images.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
3 changes: 2 additions & 1 deletion app/src/main/res/xml/backup_rules.xml
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Android 11 és régebbi: a bejelentkezés, a titkosított helyi adatbázis és a beállítások nem kerülnek a Google-mentésbe.
<!-- Android 11 és régebbi: a bejelentkezés, a titkosított helyi adatbázis, a beállítások és függő képek nem kerülnek a Google-mentésbe.
A művek, piszkozatok és olvasási adatok a felhőben vannak, bejelentkezés után visszajönnek. -->
<full-backup-content>
<exclude domain="sharedpref" path="." />
<exclude domain="database" path="." />
<exclude domain="file" path="datastore/" />
<exclude domain="file" path="pending/" />
</full-backup-content>
4 changes: 3 additions & 1 deletion app/src/main/res/xml/data_extraction_rules.xml
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Android 12+: a bejelentkezés (titkosított munkamenet), a titkosított helyi adatbázis és a beállítások sem a felhőmentésbe,
<!-- Android 12+: a bejelentkezés (titkosított munkamenet), a titkosított helyi adatbázis, a beállítások és függő képek sem a felhőmentésbe,
sem az eszközátvitelbe nem kerülnek. A Keystore-kulcs amúgy sem menthető, így a régi adat úgysem lenne olvasható. -->
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="." />
<exclude domain="database" path="." />
<exclude domain="file" path="datastore/" />
<exclude domain="file" path="pending/" />
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="." />
<exclude domain="database" path="." />
<exclude domain="file" path="datastore/" />
<exclude domain="file" path="pending/" />
</device-transfer>
</data-extraction-rules>
60 changes: 60 additions & 0 deletions supabase/2026-10-26_app_v1_4_2.sql
Original file line number Diff line number Diff line change
@@ -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$;
10 changes: 10 additions & 0 deletions supabase/tests/rls_battery_v1_3.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading