on master help sign in
Improve database safety, memory management, and error handling

- Add transactions and WAL mode to Store for concurrent write safety - Set PRAGMA busy_timeout to prevent lock contention - Add index on messages.ConversationId for query performance - Limit message loading to 200 per conversation (add loadMessagesForConversation for full history) - Bound bitmap cache at 128 entries to prevent unbounded memory growth - Safe JSON parsing in Protocol with proper null checks - Validate crypto key lengths in identityFromPrivKey, encrypt, decrypt - Deduplicate button code by extracting shadowRect and borderRect helpers - Bump schema version to 2 with active migration infrastructure

commit9e1d695b048ec6d57a3b26c3680c3bac1d92a09e authoriko <mail@iko.soy> dateSat 18 Apr 2026, 01:48 (6 months ago) parent5ed090d
6 files changed+107−72
client/Buttons.fs+23−44 client/Constants.fs+1−1 client/Crypto.fs+12−2 client/Protocol.fs+13−11 client/Store.fs+51−13 client/Styles.fs+7−1
client/Buttons.fs+23−44
@@ -19,22 +19,28 @@ module Buttons =
1919 b.AppendTranslate(x, y)
2020 b.Build() :> ITransform
2121
22+ let private shadowRect offset =
23+ Rectangle()
24+ .fill(SolidColorBrush(Colors.Black))
25+ .renderTransform(translate offset offset)
26+ .transition (
27+ TransformOperationsTransition(
28+ Avalonia.Visual.RenderTransformProperty,
29+ TimeSpan.FromMilliseconds Constants.animationDuration
30+ )
31+ .easing (Constants.easing)
32+ )
33+
34+ let private borderRect () =
35+ Rectangle().stroke(Colors.Black).strokeThickness (4.)
36+
2237 let button priority (text: string) (msg: 'msg) =
2338 Component(text) {
2439 let! pressed = Context.State(false)
2540 let offset = if pressed.Current then 4. else 8.
2641
2742 (Grid() {
28− Rectangle()
29− .fill(SolidColorBrush(Colors.Black))
30− .renderTransform(translate offset offset)
31− .transition (
32− TransformOperationsTransition(
33− Avalonia.Visual.RenderTransformProperty,
34− TimeSpan.FromMilliseconds Constants.animationDuration
35− )
36− .easing (Constants.easing)
37− )
43+ shadowRect offset
3844
3945 Rectangle()
4046 .fill (
@@ -56,7 +62,7 @@ module Buttons =
5662 .center()
5763 .margin (8.)
5864
59− Rectangle().stroke(Colors.Black).strokeThickness (4.)
65+ borderRect ()
6066 })
6167 .background(Brushes.Transparent)
6268 .onPointerPressed(fun _ -> pressed.Set true)
@@ -77,16 +83,7 @@ module Buttons =
7783 let offset = if pressed.Current then 4. else 8.
7884
7985 (Grid() {
80− Rectangle()
81− .fill(SolidColorBrush(Colors.Black))
82− .renderTransform(translate offset offset)
83− .transition (
84− TransformOperationsTransition(
85− Avalonia.Visual.RenderTransformProperty,
86− TimeSpan.FromMilliseconds Constants.animationDuration
87− )
88− .easing (Constants.easing)
89− )
86+ shadowRect offset
9087
9188 Rectangle().fill (SolidColorBrush(Colors.White))
9289
@@ -111,7 +108,7 @@ module Buttons =
111108 Image(Styles.cachedBitmap i, Stretch.UniformToFill)
112109 .clipToBounds (true)
113110
114− Rectangle().stroke(Colors.Black).strokeThickness (4.)
111+ borderRect ()
115112 })
116113 .background(Brushes.Transparent)
117114 .onPointerPressed(fun _ -> pressed.Set true)
@@ -134,16 +131,7 @@ module Buttons =
134131 let offset = if pressed.Current then 2. else 4.
135132
136133 (Grid() {
137− Rectangle()
138− .fill(SolidColorBrush(Colors.Black))
139− .renderTransform(translate offset offset)
140− .transition (
141− TransformOperationsTransition(
142− Avalonia.Visual.RenderTransformProperty,
143− TimeSpan.FromMilliseconds Constants.animationDuration
144− )
145− .easing (Constants.easing)
146− )
134+ shadowRect offset
147135
148136 Rectangle().fill (SolidColorBrush(Colors.White))
149137
@@ -162,7 +150,7 @@ module Buttons =
162150 .height(32.)
163151 .clipToBounds (true)
164152
165− Rectangle().stroke(Colors.Black).strokeThickness (4.)
153+ borderRect ()
166154 })
167155 .width(48.)
168156 .height(48.)
@@ -180,16 +168,7 @@ module Buttons =
180168 let offset = if pressed.Current then 2. else 4.
181169
182170 (Grid() {
183− Rectangle()
184− .fill(SolidColorBrush(Colors.Black))
185− .renderTransform(translate offset offset)
186− .transition (
187− TransformOperationsTransition(
188− Avalonia.Visual.RenderTransformProperty,
189− TimeSpan.FromMilliseconds Constants.animationDuration
190− )
191− .easing (Constants.easing)
192− )
171+ shadowRect offset
193172
194173 Rectangle().fill (SolidColorBrush(Colors.White))
195174
@@ -200,7 +179,7 @@ module Buttons =
200179 .textAlignment(TextAlignment.Center)
201180 .center ()
202181
203− Rectangle().stroke(Colors.Black).strokeThickness (4.)
182+ borderRect ()
204183 })
205184 .width(48.)
206185 .height(48.)
client/Constants.fs+1−1
@@ -10,4 +10,4 @@ module Constants =
1010 let hkdfInfo = "skrepka-v1"
1111 let profileId = "me"
1212 let settingsId = "settings"
13− let schemaVersion = 1
13+ let schemaVersion = 2
client/Crypto.fs+12−2
@@ -157,7 +157,11 @@ module Crypto =
157157 let private log msg = eprintfn $"[skrepka] {msg}"
158158
159159 let identityFromPrivKey (privKey: byte[]) : Identity =
160− { PrivKey = privKey; PubKeyHex = toHex privKey.[32..63] }
160+ if privKey.Length <> 64 then
161+ failwith $"Invalid Ed25519 secret key length: expected 64, got {privKey.Length}"
162+
163+ { PrivKey = privKey
164+ PubKeyHex = toHex privKey.[32..63] }
161165
162166 let generateIdentity () : Identity =
163167 let _, sk = generateSignKeyPair ()
@@ -182,6 +186,12 @@ module Crypto =
182186 let private minBlobLen = headerLen + 16 + trailerLen // 16 = AEAD tag
183187
184188 let encrypt (senderPrivKey: byte[]) (recipientEd25519Pub: byte[]) (plaintext: string) : byte[] =
189+ if senderPrivKey.Length <> 64 then
190+ failwith $"Invalid sender secret key length: expected 64, got {senderPrivKey.Length}"
191+
192+ if recipientEd25519Pub.Length <> 32 then
193+ failwith $"Invalid recipient public key length: expected 32, got {recipientEd25519Pub.Length}"
194+
185195 let senderPubKey = senderPrivKey.[32..63]
186196 let ephPub, ephPriv = generateBoxKeyPair ()
187197 let recipientX25519Pub = ed25519PkToCurve25519 recipientEd25519Pub
@@ -194,7 +204,7 @@ module Crypto =
194204 Array.concat [| ephPub; nonce; ciphertext; senderPubKey; signature |]
195205
196206 let decrypt (recipientPrivKey: byte[]) (blob: byte[]) : (string * string) option =
197− if blob.Length < minBlobLen then
207+ if recipientPrivKey.Length <> 64 || blob.Length < minBlobLen then
198208 None
199209 else
200210 try
client/Protocol.fs+13−11
@@ -47,17 +47,19 @@ module Protocol =
4747 use doc = JsonDocument.Parse(json)
4848 let root = doc.RootElement
4949
50− match root.GetProperty("type").GetString() with
51− | "text" -> Some(Envelope.TextMessage(root.GetProperty("id").GetString(), root.GetProperty("body").GetString()))
52− | "delivery.ack" ->
53− let ackIds =
54− root.GetProperty("ack_ids").EnumerateArray()
55− |> Seq.map _.GetString()
56− |> Seq.toList
57−
58− Some(Envelope.DeliveryAck ackIds)
59− | "profile" ->
60− let displayName = root.GetProperty("display_name").GetString()
50+ match tryGetJsonString root "type" with
51+ | Some "text" ->
52+ match tryGetJsonString root "id", tryGetJsonString root "body" with
53+ | Some id, Some body -> Some(Envelope.TextMessage(id, body))
54+ | _ -> None
55+ | Some "delivery.ack" ->
56+ match root.TryGetProperty("ack_ids") with
57+ | true, v ->
58+ let ackIds = v.EnumerateArray() |> Seq.map _.GetString() |> Seq.toList
59+ Some(Envelope.DeliveryAck ackIds)
60+ | _ -> None
61+ | Some "profile" ->
62+ let displayName = tryGetJsonString root "display_name" |> Option.defaultValue ""
6163 let bio = tryGetJsonString root "bio" |> Option.defaultValue ""
6264 let photo = tryGetJsonBytes root "photo"
6365
client/Store.fs+51−13
@@ -109,6 +109,7 @@ module Store =
109109 let private openConn () =
110110 let conn = new SqliteConnection($"Data Source={dbPath}")
111111 conn.Open()
112+ conn.Execute("PRAGMA busy_timeout = 5000") |> ignore
112113 conn
113114
114115 type private ByteArrayOptionHandler() =
@@ -130,6 +131,7 @@ module Store =
130131 OptionTypes.register ()
131132 SqlMapper.AddTypeHandler(ByteArrayOptionHandler())
132133 use conn = openConn ()
134+ conn.Execute("PRAGMA journal_mode=WAL") |> ignore
133135
134136 conn.Execute(
135137 "CREATE TABLE IF NOT EXISTS profile (Id TEXT PRIMARY KEY, DisplayName TEXT NOT NULL DEFAULT '', Bio TEXT NOT NULL DEFAULT '', Photo BLOB)"
@@ -170,11 +172,9 @@ module Store =
170172 log $"schema version check: {ex.Message}"
171173 0
172174
173− // Future migrations:
174− // if dbVersion < 2 then
175− // conn.Execute("ALTER TABLE ...") |> ignore
176−
177− ignore dbVersion
175+ if dbVersion < 2 then
176+ conn.Execute("CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(ConversationId)")
177+ |> ignore
178178
179179 conn.Execute(
180180 "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', @v)",
@@ -288,11 +288,9 @@ module Store =
288288 |> Map.ofSeq
289289
290290 let! messageRows =
291− select {
292− for m in messageTable do
293− selectAll
294− }
295− |> conn.SelectAsync<MessageRow>
291+ conn.QueryAsync<MessageRow>(
292+ "SELECT Id, ConversationId, Body, TimestampUnix, IsOutgoing, Status FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY ConversationId ORDER BY TimestampUnix DESC) AS rn FROM messages) WHERE rn <= 200"
293+ )
296294 |> Async.AwaitTask
297295
298296 let messages =
@@ -334,6 +332,7 @@ module Store =
334332 async {
335333 try
336334 use conn = openConn ()
335+ use tx = conn.BeginTransaction()
337336
338337 for c in data.Contacts.Values do
339338 do!
@@ -343,7 +342,8 @@ module Store =
343342 Nickname = c.Nickname
344343 DisplayName = c.DisplayName
345344 Bio = c.Bio
346− Photo = c.Photo |}
345+ Photo = c.Photo |},
346+ tx
347347 )
348348 |> Async.AwaitTask
349349 |> Async.Ignore
@@ -364,7 +364,8 @@ module Store =
364364 Body = m.Body
365365 TimestampUnix = m.Timestamp.ToUnixTimeMilliseconds()
366366 IsOutgoing = isOutgoing
367− Status = status |}
367+ Status = status |},
368+ tx
368369 )
369370 |> Async.AwaitTask
370371 |> Async.Ignore
@@ -374,10 +375,13 @@ module Store =
374375 "INSERT OR REPLACE INTO settings (Id, ServerUrl, PollCursor) VALUES (@Id, @ServerUrl, @PollCursor)",
375376 {| Id = Constants.settingsId
376377 ServerUrl = data.ServerUrl
377− PollCursor = data.PollCursor |}
378+ PollCursor = data.PollCursor |},
379+ tx
378380 )
379381 |> Async.AwaitTask
380382 |> Async.Ignore
383+
384+ tx.Commit()
381385 with ex ->
382386 log $"saveData error: {ex.Message}"
383387 }
@@ -440,3 +444,37 @@ module Store =
440444 with ex ->
441445 log $"dequeueOutbox error: {ex.Message}"
442446 }
447+
448+ let loadMessagesForConversation (conversationId: string) =
449+ async {
450+ try
451+ use conn = openConn ()
452+
453+ let! rows =
454+ conn.QueryAsync<MessageRow>(
455+ "SELECT Id, ConversationId, Body, TimestampUnix, IsOutgoing, Status FROM messages WHERE ConversationId = @convId ORDER BY TimestampUnix",
456+ {| convId = conversationId |}
457+ )
458+ |> Async.AwaitTask
459+
460+ return
461+ rows
462+ |> Seq.map (fun m ->
463+ { Id = m.Id
464+ Body = m.Body
465+ Timestamp = DateTimeOffset.FromUnixTimeMilliseconds m.TimestampUnix
466+ Direction =
467+ if not m.IsOutgoing then
468+ Incoming
469+ else
470+ Outgoing(
471+ if m.Status = 1 then
472+ DeliveryStatus.Delivered
473+ else
474+ DeliveryStatus.Sent
475+ ) })
476+ |> Seq.toList
477+ with ex ->
478+ log $"loadMessagesForConversation error: {ex.Message}"
479+ return []
480+ }
client/Styles.fs+7−1
@@ -43,7 +43,10 @@ module Styles =
4343 .Name("PART_ContentPresenter"))
4444 [ Setter(ContentPresenter.MarginProperty, Thickness(0.)) ] ]
4545
46− let private bitmapCache = System.Collections.Concurrent.ConcurrentDictionary<int, Avalonia.Media.Imaging.Bitmap>()
46+ let private maxBitmapCacheSize = 128
47+
48+ let private bitmapCache =
49+ System.Collections.Concurrent.ConcurrentDictionary<int, Avalonia.Media.Imaging.Bitmap>()
4750
4851 let cachedBitmap (bytes: byte[]) =
4952 let h =
@@ -53,5 +56,8 @@ module Styles =
5356 (if bytes.Length > 7 then System.BitConverter.ToInt32(bytes, bytes.Length / 2) else 0),
5457 (if bytes.Length > 11 then System.BitConverter.ToInt32(bytes, bytes.Length - 4) else 0))
5558
59+ if bitmapCache.Count >= maxBitmapCacheSize then
60+ bitmapCache.Clear()
61+
5662 bitmapCache.GetOrAdd(h, fun _ ->
5763 new Avalonia.Media.Imaging.Bitmap(new System.IO.MemoryStream(bytes)))