aboutsummaryrefslogtreecommitdiff
path: root/shard/lib/app/chat.ex
blob: d2530308a873f24825115b31737d143fc38745f6 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
defmodule SApp.Chat do
  @moduledoc """
  Shard application for a replicated chat room with full history.

  Chat rooms are globally identified by their channel name.
  A chat room manifest is of the form:

      %SApp.Chat.Manifest{channel: channel_name}

  A private chat room manifest is of the form:

      %SApp.Chat.PrivChat.Manifest{pk_list: ordered_list_of_authorized_pks}

  Future improvements:
  - message signing
  - storage of the chatroom messages to disk
  - use a DHT to find peers that are interested in this channel
  - epidemic broadcast (carefull not to be too costly,
    maybe by limiting the number of peers we talk to)
  - partial synchronization only == data distributed over peers
  """

  use GenServer

  require Logger
  alias SData.MerkleSearchTree, as: MST


  # =========
  # MANIFESTS
  # =========

  defmodule Manifest do
    @moduledoc"""
    Manifest for a public chat room defined by its name.
    """

    defstruct [:channel]

    defimpl Shard.Manifest do
      def module(_m), do: SApp.Chat
    end
  end

  defmodule PrivChat.Manifest do
    @moduledoc"""
    Manifest for a private chat room defined by the list of participants.

    Do not instanciate this struct directly, use `new` to ensure a canonical representation.
    """

    defstruct [:pk_list]

    @doc"""
    Ensures a canonical representation by sorting pks and removing duplicates.
    """
    def new(pk_list) do
      %__MODULE__{pk_list: pk_list |> Enum.sort |> Enum.uniq}
    end

    defimpl Shard.Manifest do
      def module(_m), do: SApp.Chat
    end
  end

  # ==========
  # MAIN LOGIC
  # ==========

  @doc """
  Start a process that connects to a given channel
  """
  def start_link(manifest) do
    GenServer.start_link(__MODULE__, manifest)
  end

  @doc """
  Initialize channel process.
  """
  def init(manifest) do
    id = SData.term_hash manifest

    netgroup = case manifest do
      %Manifest{channel: _channel} ->
        %SNet.PubShardGroup{id: id}
      %PrivChat.Manifest{pk_list: pk_list} ->
        %SNet.PrivGroup{pk_list: pk_list}
    end
    Shard.Manager.dispatch_to(id, nil, self())
    {:ok, page_store} = SApp.PageStore.start_link(id, :page_store, netgroup)
    {root, read} = case Shard.Manager.load_state id do
      %{root: root, read: read} -> {root, read}
      _ -> {nil, nil}
    end
    root = cond do
      root == nil -> nil
      GenServer.call(page_store, {:have_rec, root}) -> root
      true ->
        Logger.warn "Not all pages for saved root were saved, restarting from an empty state!"
        nil
    end
    mst = %MST{store: %SApp.PageStore{pid: page_store},
               cmp: &msg_cmp/2,
               root: root}
    SNet.Group.init_lookup(netgroup, self())
    {:ok,
      %{id: id,
        netgroup: netgroup,
        manifest: manifest,
        page_store: page_store,
        mst: mst,
        subs: MapSet.new,
        read: read,
      }
    }
  end

  @doc """
  Implementation of the :manifest call that returns the chat room's manifest
  """
  def handle_call(:manifest, _from, state) do
    {:reply, state.manifest, state}
  end

  def handle_call({:read_history, top_bound, num}, _from, state) do
    ret = MST.last(state.mst, top_bound, num)
    {:reply, ret, state}
  end

  def handle_call(:has_unread, _from, state) do
    if state.mst.root != state.read do
      case MST.last(state.mst, nil, 1) do
        [{{_, msgbin, _}, true}] ->
          {ts, _} = SData.term_unbin msgbin
          {:reply, ts, state}
        [] ->
          {:reply, nil, state}
      end
    else
      {:reply, nil, state}
    end
  end

  def handle_cast(:mark_read, state) do
    state = %{state | read: state.mst.root}
    save_state(state)
    {:noreply, state}
  end

  @doc """
  Implementation of the :chat_send handler. This is the main handler that is used
  to send a message to the chat room. Puts the message in the store and syncs
  with all connected peers.
  """
  def handle_cast({:chat_send, pk, msg}, state) do
    next_ts = case MST.last(state.mst, nil, 1) do
      [] -> System.os_time :seconds
      [{{_, msgbin, _}, true}] ->
        {ts, _msg} = SData.term_unbin msgbin
        max(ts + 1, System.os_time :seconds)
    end
    msgbin = SData.term_bin {next_ts, msg}
    {:ok, sign} = Shard.Keys.sign_detached(pk, msgbin)
    msgitem = {pk, msgbin, sign}

    prev_root = state.mst.root
    mst = MST.insert(state.mst, msgitem)
    state = %{state | mst: mst}
    save_state(state)

    for pid <- state.subs do
      if Process.alive?(pid) do
        send(pid, {:chat_send, state.manifest, msgitem})
      end
    end

    notif = {state.id, nil, {:append, prev_root, msgitem, mst.root}}
    SNet.Group.broadcast(state.netgroup, notif)

    {:noreply, state}
  end

  def handle_cast({:peer_connected, conn_pid}, state) do
    GenServer.cast(conn_pid, {:send_msg, {:interested, [state.id]}})
    {:noreply, state}
  end

  @doc """
  Implementation of the :interested handler, this is called when a peer we are
  connected to asks to recieve data for this channel.
  """
  def handle_cast({:interested, conn_pid, auth}, state) do
    if SNet.Group.in_group?(state.netgroup, conn_pid, auth) do
      SNet.Manager.send_pid(conn_pid, {state.id, nil, {:root, state.mst.root, true}})
    end
    {:noreply, state}
  end

  def handle_cast({:subscribe, pid}, state) do
    Process.monitor(pid)
    new_subs = MapSet.put(state.subs, pid)
    {:noreply, %{ state | subs: new_subs }}
  end

  @doc """
  Implementation of the :msg handler, which is the main handler for messages
  comming from other peers concerning this chat room.
  """
  def handle_cast({:msg, conn_pid, auth, _shard_id, nil, msg}, state) do
    if not SNet.Group.in_group?(state.netgroup, conn_pid, auth) do
      # Ignore message
      {:noreply, state}
    else
      state = case msg do
        {:get_manifest} ->
          SNet.Manager.send_pid(conn_pid, {state.id, nil, {:manifest, state.manifest}})
          state
        {:append, prev_root, msgitem, new_root} ->
          # Append message: one single mesage has arrived
          if new_root == state.mst.root do
            # We already have the message, do nothing
            state
          else
            # Try adding the message
            {pk, bin, sign} = msgitem
            if Shard.Keys.verify(pk, bin, sign) == :ok do
              if prev_root == state.mst.root do
                # Only one new message, insert it directly
                mst2 = MST.insert(state.mst, msgitem)
                if mst2.root == new_root do
                  state = %{state | mst: mst2}
                  GenServer.cast(state.page_store, {:set_roots, [mst2.root]})
                  save_state(state)
                  msg_callback(state, msgitem)
                  SNet.Group.broadcast(state.netgroup, {state.id, nil, msg}, exclude_pid: [conn_pid])
                  state
                else
                  Logger.warn("Invalid new root after inserting same message item!")
                  state
                end
              else
                # Not a simple one-insertion transition, look at the whole tree
                init_merge(state, new_root, conn_pid)
              end
            else
              Logger.warn("Received message with invalid signature")
              state
            end
          end
        {:root, new_root, ask_reply} ->
          state = if new_root == state.mst.root do
            # already up to date, ignore
            state
          else
            init_merge(state, new_root, conn_pid)
          end
          if ask_reply do
            SNet.Manager.send_pid(conn_pid, {state.id, nil, {:root, state.mst.root, false}})
          end
          state
        x ->
          Logger.info("Unhandled message: #{inspect x}")
          state
      end
      {:noreply, state}
    end
  end

  defp init_merge(state, new_root, source_peer_pid) do
    old_root = state.mst.root

    if new_root == nil do
      state
    else
      # TODO: make the merge asynchronous
      
      Logger.info("Starting merge for #{inspect state.manifest}, merging root: #{new_root|>Base.encode16}")

      prev_last = for {x, true} <- MST.last(state.mst, nil, 100), into: MapSet.new, do: x

      mgmst = %{state.mst | root: new_root}
      mgmst = put_in(mgmst.store.prefer_ask, [source_peer_pid])
      mst = MST.merge(state.mst, mgmst)

      new = for {x, true} <- MST.last(mst, nil, 100),
              not MapSet.member?(prev_last, x)
            do x end

      correct = for x <- new do
        {pk, bin, sign} = x
        Shard.Keys.verify(pk, bin, sign)
      end

      if Enum.all? correct do
        for x <- new do
          msg_callback(state, x)
        end
        GenServer.cast(state.page_store, {:set_roots, [mst.root]})
        state = %{state | mst: mst}
        save_state(state)
        if state.mst.root != old_root do
          SNet.Group.broadcast(state.netgroup, {state.id, nil, {:root, state.mst.root, false}}, exclude_pid: [source_peer_pid])
        end
        state
      else
        Logger.warn("Incorrect signatures somewhere while merging, dropping merged data")
        state
      end
    end
  end

  def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do
    new_subs = MapSet.delete(state.subs, pid)
    {:noreply, %{ state | subs: new_subs }}
  end

  defp save_state(state) do
    Shard.Manager.save_state(state.id, %{root: state.mst.root, read: state.read})
  end

  defp msg_callback(state, {pk, msgbin, sign}) do
    for pid <- state.subs do
      if Process.alive?(pid) do
        send(pid, {:chat_recv, state.manifest, {pk, msgbin, sign}})
      end
    end
  end

  defp msg_cmp({pk1, msgbin1, _sign1}, {pk2, msgbin2, _sign2}) do
    {ts1, msg1} = SData.term_unbin msgbin1
    {ts2, msg2} = SData.term_unbin msgbin2
    cond do
      ts1 > ts2 -> :after
      ts1 < ts2 -> :before
      pk1 > pk2 -> :after
      pk1 < pk2 -> :before
      msg1 > msg2 -> :after
      msg1 < msg2 -> :before
      true -> :duplicate
    end
  end 

  # ================
  # PUBLIC INTERFACE
  # ================
  
  @doc"""
  Subscribe to notifications for this chat room.

  The process calling this function will start recieving messages of the form:

    {:chat_recv, manifest, {pk, msgbin, sign}}

  or

    {:chat_send, manifest, {pk, msgbin, sign}}

  msgbin can be used in the following way:

    {timestamp, message} = SData.term_unbin msgbin
  """
  def subscribe(shard_pid) do
    GenServer.cast(shard_pid, {:subscribe, self()})
  end

  @doc"""
  Send a message to a chat room.
  """
  def chat_send(shard_pid, pk, msg) do
    GenServer.cast(shard_pid, {:chat_send, pk, msg})
  end

  @doc"""
  Read the history of a chat room.

  The second argument is the last message to read.
  If nil, will read the n last messages.
  If not nill, will read the n last messages until the specified bound.
  """
  def read_history(shard_pid, bound, n) do
    GenServer.call(shard_pid, {:read_history, bound, n})
  end

  @doc"""
  Return a shard's manifest from its pid.
  """
  def get_manifest(shard_pid) do
    GenServer.call(shard_pid, :manifest)
  end

  @doc"""
  Returns timestamp of last message if chat room has unread messages, nil otherwise.
  """
  def has_unread?(shard_pid) do
    GenServer.call(shard_pid, :has_unread)
  end

  @doc"""
  Mark all messages as read
  """
  def mark_read(shard_pid) do
    GenServer.cast(shard_pid, :mark_read)
  end
end