on master help sign in
Improve database safety, memory management, and error handling
9e1d695iko
client/Buttons.fs+23−44
@@ -19,22 +19,28 @@ module Buttons =
19 b.AppendTranslate(x, y)
19 b.AppendTranslate(x, y)
20 b.Build() :> ITransform
20 b.Build() :> ITransform
21
21
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+
22 let button priority (text: string) (msg: 'msg) =
37 let button priority (text: string) (msg: 'msg) =
23 Component(text) {
38 Component(text) {
24 let! pressed = Context.State(false)
39 let! pressed = Context.State(false)
25 let offset = if pressed.Current then 4. else 8.
40 let offset = if pressed.Current then 4. else 8.
26
41
27 (Grid() {
42 (Grid() {
28− Rectangle()
43+ shadowRect offset
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− )
38
44
39 Rectangle()
45 Rectangle()
40 .fill (
46 .fill (
@@ -56,7 +62,7 @@ module Buttons =
56 .center()
62 .center()
57 .margin (8.)
63 .margin (8.)
58
64
59− Rectangle().stroke(Colors.Black).strokeThickness (4.)
65+ borderRect ()
60 })
66 })
61 .background(Brushes.Transparent)
67 .background(Brushes.Transparent)
62 .onPointerPressed(fun _ -> pressed.Set true)
68 .onPointerPressed(fun _ -> pressed.Set true)
@@ -77,16 +83,7 @@ module Buttons =
77 let offset = if pressed.Current then 4. else 8.
83 let offset = if pressed.Current then 4. else 8.
78
84
79 (Grid() {
85 (Grid() {
80− Rectangle()
86+ shadowRect offset
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− )
90
87
91 Rectangle().fill (SolidColorBrush(Colors.White))
88 Rectangle().fill (SolidColorBrush(Colors.White))
92
89
@@ -111,7 +108,7 @@ module Buttons =
111 Image(Styles.cachedBitmap i, Stretch.UniformToFill)
108 Image(Styles.cachedBitmap i, Stretch.UniformToFill)
112 .clipToBounds (true)
109 .clipToBounds (true)
113
110
114− Rectangle().stroke(Colors.Black).strokeThickness (4.)
111+ borderRect ()
115 })
112 })
116 .background(Brushes.Transparent)
113 .background(Brushes.Transparent)
117 .onPointerPressed(fun _ -> pressed.Set true)
114 .onPointerPressed(fun _ -> pressed.Set true)
@@ -134,16 +131,7 @@ module Buttons =
134 let offset = if pressed.Current then 2. else 4.
131 let offset = if pressed.Current then 2. else 4.
135
132
136 (Grid() {
133 (Grid() {
137− Rectangle()
134+ shadowRect offset
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− )
147
135
148 Rectangle().fill (SolidColorBrush(Colors.White))
136 Rectangle().fill (SolidColorBrush(Colors.White))
149
137
@@ -162,7 +150,7 @@ module Buttons =
162 .height(32.)
150 .height(32.)
163 .clipToBounds (true)
151 .clipToBounds (true)
164
152
165− Rectangle().stroke(Colors.Black).strokeThickness (4.)
153+ borderRect ()
166 })
154 })
167 .width(48.)
155 .width(48.)
168 .height(48.)
156 .height(48.)
@@ -180,16 +168,7 @@ module Buttons =
180 let offset = if pressed.Current then 2. else 4.
168 let offset = if pressed.Current then 2. else 4.
181
169
182 (Grid() {
170 (Grid() {
183− Rectangle()
171+ shadowRect offset
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− )
193
172
194 Rectangle().fill (SolidColorBrush(Colors.White))
173 Rectangle().fill (SolidColorBrush(Colors.White))
195
174
@@ -200,7 +179,7 @@ module Buttons =
200 .textAlignment(TextAlignment.Center)
179 .textAlignment(TextAlignment.Center)
201 .center ()
180 .center ()
202
181
203− Rectangle().stroke(Colors.Black).strokeThickness (4.)
182+ borderRect ()
204 })
183 })
205 .width(48.)
184 .width(48.)
206 .height(48.)
185 .height(48.)
client/Constants.fs+1−1
@@ -10,4 +10,4 @@ module Constants =
10 let hkdfInfo = "skrepka-v1"
10 let hkdfInfo = "skrepka-v1"
11 let profileId = "me"
11 let profileId = "me"
12 let settingsId = "settings"
12 let settingsId = "settings"
13− let schemaVersion = 1
13+ let schemaVersion = 2
client/Crypto.fs+12−2
@@ -157,7 +157,11 @@ module Crypto =
157 let private log msg = eprintfn $"[skrepka] {msg}"
157 let private log msg = eprintfn $"[skrepka] {msg}"
158
158
159 let identityFromPrivKey (privKey: byte[]) : Identity =
159 let identityFromPrivKey (privKey: byte[]) : Identity =
160+ if privKey.Length <> 64 then
161+ failwith $"Invalid Ed25519 secret key length: expected 64, got {privKey.Length}"
162+
163+ { PrivKey = privKey
160− { PrivKey = privKey; PubKeyHex = toHex privKey.[32..63] }
164+ PubKeyHex = toHex privKey.[32..63] }
161
165
162 let generateIdentity () : Identity =
166 let generateIdentity () : Identity =
163 let _, sk = generateSignKeyPair ()
167 let _, sk = generateSignKeyPair ()
@@ -182,6 +186,12 @@ module Crypto =
182 let private minBlobLen = headerLen + 16 + trailerLen // 16 = AEAD tag
186 let private minBlobLen = headerLen + 16 + trailerLen // 16 = AEAD tag
183
187
184 let encrypt (senderPrivKey: byte[]) (recipientEd25519Pub: byte[]) (plaintext: string) : byte[] =
188 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+
185 let senderPubKey = senderPrivKey.[32..63]
195 let senderPubKey = senderPrivKey.[32..63]
186 let ephPub, ephPriv = generateBoxKeyPair ()
196 let ephPub, ephPriv = generateBoxKeyPair ()
187 let recipientX25519Pub = ed25519PkToCurve25519 recipientEd25519Pub
197 let recipientX25519Pub = ed25519PkToCurve25519 recipientEd25519Pub
@@ -194,7 +204,7 @@ module Crypto =
194 Array.concat [| ephPub; nonce; ciphertext; senderPubKey; signature |]
204 Array.concat [| ephPub; nonce; ciphertext; senderPubKey; signature |]
195
205
196 let decrypt (recipientPrivKey: byte[]) (blob: byte[]) : (string * string) option =
206 let decrypt (recipientPrivKey: byte[]) (blob: byte[]) : (string * string) option =
197− if blob.Length < minBlobLen then
207+ if recipientPrivKey.Length <> 64 || blob.Length < minBlobLen then
198 None
208 None
199 else
209 else
200 try
210 try
client/Protocol.fs+13−11
@@ -47,17 +47,19 @@ module Protocol =
47 use doc = JsonDocument.Parse(json)
47 use doc = JsonDocument.Parse(json)
48 let root = doc.RootElement
48 let root = doc.RootElement
49
49
50− match root.GetProperty("type").GetString() with
50+ match tryGetJsonString root "type" with
51+ | Some "text" ->
52+ match tryGetJsonString root "id", tryGetJsonString root "body" with
51− | "text" -> Some(Envelope.TextMessage(root.GetProperty("id").GetString(), root.GetProperty("body").GetString()))
53+ | Some id, Some body -> Some(Envelope.TextMessage(id, body))
54+ | _ -> None
52− | "delivery.ack" ->
55+ | Some "delivery.ack" ->
53− let ackIds =
56+ match root.TryGetProperty("ack_ids") with
54− root.GetProperty("ack_ids").EnumerateArray()
57+ | true, v ->
55− |> Seq.map _.GetString()
58+ let ackIds = v.EnumerateArray() |> Seq.map _.GetString() |> Seq.toList
56− |> Seq.toList
57−
58− Some(Envelope.DeliveryAck ackIds)
59+ Some(Envelope.DeliveryAck ackIds)
60+ | _ -> None
59− | "profile" ->
61+ | Some "profile" ->
60− let displayName = root.GetProperty("display_name").GetString()
62+ let displayName = tryGetJsonString root "display_name" |> Option.defaultValue ""
61 let bio = tryGetJsonString root "bio" |> Option.defaultValue ""
63 let bio = tryGetJsonString root "bio" |> Option.defaultValue ""
62 let photo = tryGetJsonBytes root "photo"
64 let photo = tryGetJsonBytes root "photo"
63
65
client/Store.fs+51−13
@@ -109,6 +109,7 @@ module Store =
109 let private openConn () =
109 let private openConn () =
110 let conn = new SqliteConnection($"Data Source={dbPath}")
110 let conn = new SqliteConnection($"Data Source={dbPath}")
111 conn.Open()
111 conn.Open()
112+ conn.Execute("PRAGMA busy_timeout = 5000") |> ignore
112 conn
113 conn
113
114
114 type private ByteArrayOptionHandler() =
115 type private ByteArrayOptionHandler() =
@@ -130,6 +131,7 @@ module Store =
130 OptionTypes.register ()
131 OptionTypes.register ()
131 SqlMapper.AddTypeHandler(ByteArrayOptionHandler())
132 SqlMapper.AddTypeHandler(ByteArrayOptionHandler())
132 use conn = openConn ()
133 use conn = openConn ()
134+ conn.Execute("PRAGMA journal_mode=WAL") |> ignore
133
135
134 conn.Execute(
136 conn.Execute(
135 "CREATE TABLE IF NOT EXISTS profile (Id TEXT PRIMARY KEY, DisplayName TEXT NOT NULL DEFAULT '', Bio TEXT NOT NULL DEFAULT '', Photo BLOB)"
137 "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 =
170 log $"schema version check: {ex.Message}"
172 log $"schema version check: {ex.Message}"
171 0
173 0
172
174
173− // Future migrations:
174− // if dbVersion < 2 then
175+ if dbVersion < 2 then
175− // conn.Execute("ALTER TABLE ...") |> ignore
176+ conn.Execute("CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(ConversationId)")
176−
177+ |> ignore
177− ignore dbVersion
178
178
179 conn.Execute(
179 conn.Execute(
180 "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', @v)",
180 "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', @v)",
@@ -288,11 +288,9 @@ module Store =
288 |> Map.ofSeq
288 |> Map.ofSeq
289
289
290 let! messageRows =
290 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+ )
296 |> Async.AwaitTask
294 |> Async.AwaitTask
297
295
298 let messages =
296 let messages =
@@ -334,6 +332,7 @@ module Store =
334 async {
332 async {
335 try
333 try
336 use conn = openConn ()
334 use conn = openConn ()
335+ use tx = conn.BeginTransaction()
337
336
338 for c in data.Contacts.Values do
337 for c in data.Contacts.Values do
339 do!
338 do!
@@ -343,7 +342,8 @@ module Store =
343 Nickname = c.Nickname
342 Nickname = c.Nickname
344 DisplayName = c.DisplayName
343 DisplayName = c.DisplayName
345 Bio = c.Bio
344 Bio = c.Bio
346− Photo = c.Photo |}
345+ Photo = c.Photo |},
346+ tx
347 )
347 )
348 |> Async.AwaitTask
348 |> Async.AwaitTask
349 |> Async.Ignore
349 |> Async.Ignore
@@ -364,7 +364,8 @@ module Store =
364 Body = m.Body
364 Body = m.Body
365 TimestampUnix = m.Timestamp.ToUnixTimeMilliseconds()
365 TimestampUnix = m.Timestamp.ToUnixTimeMilliseconds()
366 IsOutgoing = isOutgoing
366 IsOutgoing = isOutgoing
367− Status = status |}
367+ Status = status |},
368+ tx
368 )
369 )
369 |> Async.AwaitTask
370 |> Async.AwaitTask
370 |> Async.Ignore
371 |> Async.Ignore
@@ -374,10 +375,13 @@ module Store =
374 "INSERT OR REPLACE INTO settings (Id, ServerUrl, PollCursor) VALUES (@Id, @ServerUrl, @PollCursor)",
375 "INSERT OR REPLACE INTO settings (Id, ServerUrl, PollCursor) VALUES (@Id, @ServerUrl, @PollCursor)",
375 {| Id = Constants.settingsId
376 {| Id = Constants.settingsId
376 ServerUrl = data.ServerUrl
377 ServerUrl = data.ServerUrl
377− PollCursor = data.PollCursor |}
378+ PollCursor = data.PollCursor |},
379+ tx
378 )
380 )
379 |> Async.AwaitTask
381 |> Async.AwaitTask
380 |> Async.Ignore
382 |> Async.Ignore
383+
384+ tx.Commit()
381 with ex ->
385 with ex ->
382 log $"saveData error: {ex.Message}"
386 log $"saveData error: {ex.Message}"
383 }
387 }
@@ -440,3 +444,37 @@ module Store =
440 with ex ->
444 with ex ->
441 log $"dequeueOutbox error: {ex.Message}"
445 log $"dequeueOutbox error: {ex.Message}"
442 }
446 }
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 =
43 .Name("PART_ContentPresenter"))
43 .Name("PART_ContentPresenter"))
44 [ Setter(ContentPresenter.MarginProperty, Thickness(0.)) ] ]
44 [ Setter(ContentPresenter.MarginProperty, Thickness(0.)) ] ]
45
45
46+ let private maxBitmapCacheSize = 128
47+
48+ let private bitmapCache =
46− let private bitmapCache = System.Collections.Concurrent.ConcurrentDictionary<int, Avalonia.Media.Imaging.Bitmap>()
49+ System.Collections.Concurrent.ConcurrentDictionary<int, Avalonia.Media.Imaging.Bitmap>()
47
50
48 let cachedBitmap (bytes: byte[]) =
51 let cachedBitmap (bytes: byte[]) =
49 let h =
52 let h =
@@ -53,5 +56,8 @@ module Styles =
53 (if bytes.Length > 7 then System.BitConverter.ToInt32(bytes, bytes.Length / 2) else 0),
56 (if bytes.Length > 7 then System.BitConverter.ToInt32(bytes, bytes.Length / 2) else 0),
54 (if bytes.Length > 11 then System.BitConverter.ToInt32(bytes, bytes.Length - 4) else 0))
57 (if bytes.Length > 11 then System.BitConverter.ToInt32(bytes, bytes.Length - 4) else 0))
55
58
59+ if bitmapCache.Count >= maxBitmapCacheSize then
60+ bitmapCache.Clear()
61+
56 bitmapCache.GetOrAdd(h, fun _ ->
62 bitmapCache.GetOrAdd(h, fun _ ->
57 new Avalonia.Media.Imaging.Bitmap(new System.IO.MemoryStream(bytes)))
63 new Avalonia.Media.Imaging.Bitmap(new System.IO.MemoryStream(bytes)))