💾 Archived View for gemini.rmf-dev.com › repo › Vaati › gmiChat › files › 9cc1c2ce3aba62e30c0177ad93… captured on 2023-05-24 at 18:07:12. Gemini links have been rewritten to link to archived content
⬅️ Previous capture (2023-03-20)
-=-=-=-=-=-=-
0 require Logger
1 require Ecto.Query
2
3 # Gemini Chat
4
5 defmodule Gmichat do
6
7 @max_register 3
8 @max_attemps_ip 10
9 @max_attemps_account 50
10 @max_messages 3
11 @max_messages_timeout 2
12
13 defp main_page(args) do
14 if get_user(args[:cert]) == nil do
15 Gmi.content(
16 "# GmiChat\n\n" <>
17 "Chat platform for the Gemini protocol\n" <>
18 "A client certificate is required to register and to login\n\n" <>
19 "=>/login Login\n" <>
20 "=>/register Register\n\n" <>
21 "## Softwares\n\n" <>
22 "=>gemini://gemini.rmf-dev.com/repo/Vaati/gmiChat Source code\n" <>
23 "=>gemini://gemini.rmf-dev.com/repo/Vaati/Vgmi/readme Recommended client"
24 )
25 else
26 Gmi.redirect("/account")
27 end
28 end
29
30 defp can_attempt(table, key, threshold) do
31 rows = :ets.lookup(table, key)
32 rows == [] or elem(hd(rows), 1) < threshold
33 end
34
35 defp add_attempt(table, key) do
36 value = :ets.lookup(table, key)
37 value = if value == [] do 0 else elem(hd(value), 1) end
38 :ets.insert(table, {
39 key, value + 1
40 })
41 end
42
43 defp ask_input(args, field, to) do
44 cond do
45 args[:cert] == nil ->
46 Gmi.cert_required("Certificate required to register")
47 args[:query] == "" ->
48 Gmi.input(field)
49 true ->
50 Gmi.redirect(to <> args[:query])
51 end
52 end
53
54 defp write_msg(message, from, dst, dm) do
55 msg = %Gmichat.Message{
56 message: message,
57 user_id: from,
58 destination: dst,
59 timestamp: System.system_time(:second),
60 dm: dm
61 }
62 last_message = :ets.lookup(:messages_rate, from)
63 last_message = if last_message != [] do
64 elem(hd(last_message), 1)
65 else
66 last_message
67 end
68 last_message =
69 if last_message == [] or elem(last_message, 0) + @max_messages_timeout
70 < System.system_time(:second) do
71 {System.system_time(:second), 1}
72 else
73 {elem(last_message, 0), elem(last_message, 1) + 1}
74 end
75 if elem(last_message, 1) > @max_messages do
76 "You sent too many messages in a short period of time"
77 else
78 :ets.insert(:messages_rate, {from, last_message})
79 {state, ret} = msg |> Gmichat.Repo.insert
80 if state == :ok do
81 :ok
82 else
83 elem(elem(hd(ret.errors), 1), 0)
84 end
85 end
86 end
87
88 defp create_user(name, password) do
89 user = %Gmichat.User{
90 name: String.downcase(name),
91 password: password,
92 timezone: 0,
93 linelength: 0,
94 leftmargin: 0,
95 timestamp: System.system_time(:second)
96 }
97 {state, ret} = Gmichat.User.changeset(user, %{}) |> Gmichat.Repo.insert
98 if state == :ok do
99 :ok
100 else
101 to_string(elem(hd(ret.errors), 0)) <> " " <> elem(elem(hd(ret.errors), 1), 0)
102 end
103 end
104
105 defp register_complete(args) do
106 cond do
107 args[:cert] == nil ->
108 Gmi.cert_required("Certificate required to register")
109 args[:query] == "" ->
110 Gmi.input_secret("Password")
111 !can_attempt(:registrations, elem(args[:addr], 0), @max_register) ->
112 Gmi.failure("Temporary registration limit reached for your ip")
113 true ->
114 ret = create_user(args[:name], args[:query])
115 if ret == :ok do
116 add_attempt(:registrations, elem(args[:addr], 0))
117 Gmi.redirect("/register/x/success")
118 else
119 Gmi.failure(ret)
120 end
121
122 end
123 end
124
125 defp try_login(name, password, addr) do
126 name = String.downcase(name)
127 cond do
128 !can_attempt(:account_attempts, name, @max_attemps_account) ->
129 {:error, "Too many login attempts for this account"}
130 !can_attempt(:ip_attempts, addr, @max_attemps_ip) ->
131 {:error, "Too many login attempts from your ip"}
132 true ->
133 user = Gmichat.User |> Gmichat.Repo.get_by(name: name)
134 if user != nil and Argon2.verify_pass(password, user.password) do
135 {:ok, user}
136 else
137 add_attempt(:account_attempts, name)
138 add_attempt(:ip_attempts, addr)
139 {:error, "Invalid username or password"}
140 end
141 end
142 end
143
144 defp login_complete(args) do
145 cond do
146 args[:cert] == nil ->
147 Gmi.cert_required("Certificate required to register")
148 args[:query] == "" ->
149 Gmi.input_secret("Password")
150 true ->
151 {state, ret} = try_login(args[:name], args[:query], elem(args[:addr], 0))
152 if state == :ok do
153 :ets.insert(:users, {
154 args[:cert], %{ret | password: :ignore}
155 })
156 Gmi.redirect("/account")
157 else
158 Gmi.failure(ret)
159 end
160 end
161 end
162
163 defp format_message(message, llength, margin, pos \\ 0) do
164 if llength == 0 do
165 String.duplicate(" ", margin) <> message <> "\n"
166 else
167 lastline = pos + llength >= String.length(message)
168 length = if lastline do
169 String.length(message) - pos
170 else
171 llength
172 end
173 new_message =
174 String.slice(message, 0, pos) <>
175 String.duplicate(" ", margin) <>
176 String.slice(message, pos, length) <> "\n" <>
177 String.slice(message, pos + length, String.length(message) - pos - length)
178 if lastline do
179 new_message
180 else
181 format_message(new_message,
182 llength, margin, pos + llength + margin + 1)
183 end
184 end
185 end
186
187 defp show_messages(rows, timezone, llength, margin, out \\ "") do
188 if rows == [] do
189 out
190 else
191 row = hd(rows)
192 {:ok, time} = DateTime.from_unix(row.timestamp + timezone * 3600)
193 show_messages(tl(rows), timezone, llength, margin,
194 format_message(
195 "[" <> String.slice(DateTime.to_string(time), 0..-2) <> "] "
196 <> "<" <> row.user.name <> "> "
197 <> row.message, llength, margin) <> out)
198 end
199 end
200
201 defp account(_, user) do
202 results = Ecto.Query.from m in Gmichat.Message,
203 order_by: [desc: m.timestamp],
204 limit: 30,
205 where: m.dm == false and m.destination == 0
206
207 results = results |> Ecto.Query.preload(:user) |> Gmichat.Repo.all
208 content = "# Connected as " <> user.name <>
209 "\n\n" <> "## Public chat\n"
210 <> show_messages(results, user.timezone, user.linelength, user.leftmargin)
211 <> "\n=>/account/write Send message"
212 <> "\n=>/account/dm Send direct message"
213 <> "\n=>/account/contacts Contacts"
214 <> "\n\n## Options\n"
215 <> "\n=>/account/zone Set time zone [UTC "
216 <> to_string(user.timezone) <> "]"
217 <> "\n=>/account/llength Set line length ["
218 <> to_string(user.linelength) <> "]"
219 <> "\n=>/account/margin Set left margin ["
220 <> to_string(user.leftmargin) <> "]"
221 <> "\n\n=>/account/disconnect Disconnect"
222 Gmi.content(content)
223 end
224
225 defp account_zone(args, user) do
226 if args[:query] == "" do
227 Gmi.input("UTC offset")
228 else
229 ret = Integer.parse(args[:query])
230 ret = if ret == :error do ret else elem(ret, 0) end
231 cond do
232 ret == :error ->
233 Gmi.bad_request("Invalid value")
234 ret < -14 or ret > 14 ->
235 Gmi.bad_request("Offset must be between -14 and 14")
236 true ->
237 Ecto.Changeset.change(user, %{timezone: ret}) |>
238 Gmichat.User.changeset |>
239 Gmichat.Repo.update!
240 :ets.insert(:users, {args[:cert], %{user | timezone: ret}})
241 Gmi.redirect("/account")
242 end
243 end
244 end
245
246 defp account_line_length(args, user) do
247 if args[:query] == "" do
248 Gmi.input("Chat line length (0 = no limit)")
249 else
250 ret = Integer.parse(args[:query])
251 ret = if ret == :error do ret else elem(ret, 0) end
252 cond do
253 ret == :error ->
254 Gmi.bad_request("Invalid value")
255 ret < 0 or ret > 1024 ->
256 Gmi.bad_request("Line length must be between 0 and 1024")
257 true ->
258 Ecto.Changeset.change(user, %{linelength: ret}) |>
259 Gmichat.User.changeset |>
260 Gmichat.Repo.update!
261 :ets.insert(:users, {args[:cert], %{user | linelength: ret}})
262 Gmi.redirect("/account")
263 end
264 end
265 end
266
267 defp account_left_margin(args, user) do
268 if args[:query] == "" do
269 Gmi.input("Left margin (0 = no margin)")
270 else
271 ret = Integer.parse(args[:query])
272 ret = if ret == :error do ret else elem(ret, 0) end
273 cond do
274 ret == :error ->
275 Gmi.bad_request("Invalid value")
276 ret < 0 or ret > 4096 ->
277 Gmi.bad_request("Left margin must be between 0 and 4096")
278 true ->
279 Ecto.Changeset.change(user, %{leftmargin: ret}) |>
280 Gmichat.User.changeset |>
281 Gmichat.Repo.update!
282 :ets.insert(:users, {args[:cert], %{user | leftmargin: ret}})
283 Gmi.redirect("/account")
284 end
285 end
286 end
287
288 defp account_write(args, user) do
289 if args[:query] == "" do
290 Gmi.input(user.name)
291 else
292 ret = write_msg(args[:query], user.id, 0, false)
293 if ret == :ok do
294 Gmi.redirect("/account")
295 else
296 Gmi.failure(ret)
297 end
298 end
299 end
300
301 def dm(args, user) do
302 to = Gmichat.User |> Gmichat.Repo.get_by(name: String.downcase(args[:name]))
303 if to != nil and user.id != to.id do
304 results = Ecto.Query.from m in Gmichat.Message,
305 order_by: [desc: m.timestamp],
306 limit: 30,
307 where: m.dm == true and
308 (m.destination == ^to.id and m.user_id == ^user.id) or
309 (m.destination == ^user.id and m.user_id == ^to.id)
310 results = results |> Ecto.Query.preload(:user) |> Gmichat.Repo.all
311 Gmi.content(
312 "=>/account/contacts Go back\n\n" <>
313 "# " <> to.name <> " - Direct messages\n\n" <>
314 show_messages(results, user.timezone, user.linelength, user.leftmargin) <>
315 "\n=>/account/dm/" <> to.name <> "/write Send message")
316 else
317 Gmi.bad_request("User " <> args[:name] <> " not found")
318 end
319 end
320
321 def dm_write(args, user) do
322 if args[:query] == "" do
323 Gmi.input("Send message")
324 else
325 to = Gmichat.User |> Gmichat.Repo.get_by(name: args[:name])
326 if to != nil and user.id != to.id do
327 write_msg(args[:query], user.id, to.id, true)
328 Gmi.redirect("/account/dm/" <> to.name)
329 else
330 Gmi.bad_request("User " <> args[:name] <> " not found")
331 end
332 end
333 end
334
335 def show_contacts(rows, out \\ "") do
336 if rows == [] do
337 out
338 else
339 name = hd(hd(rows))
340 show_contacts(tl(rows), out <> "=>/account/dm/" <>
341 name <> " " <> name <> "\n")
342 end
343 end
344
345 def contacts(_, user) do
346 query = """
347 SELECT name FROM
348 (SELECT DISTINCT
349 (CASE WHEN user_id=$1::integer THEN destination ELSE user_id END) AS uid,
350 MAX(timestamp) as max
351 FROM messages WHERE dm = 1 AND (destination = $1::integer OR user_id = $1::integer)
352 GROUP BY uid) dms
353 INNER JOIN users u ON u.id = dms.uid
354 ORDER BY dms.max DESC;
355 """
356
357 results = Ecto.Adapters.SQL.query!(Gmichat.Repo, query, [user.id])
358
359 Gmi.content("=>/account Go back\n\n# Contacts\n\n" <>
360 show_contacts(results.rows))
361 end
362
363 def connected(args, func) do
364 user = get_user(args[:cert])
365 if user == nil do
366 Gmi.redirect("/")
367 else
368 func.(args, user)
369 end
370 end
371
372 def get_user(cert) do
373 if cert == nil do
374 nil
375 else
376 rows = :ets.lookup(:users, cert)
377 if rows == [] do
378 nil
379 else
380 elem(hd(rows), 1)
381 end
382 end
383 end
384
385 defp decrease_limit_iter(table, rows) do
386 if rows != [] do
387 :ets.insert(table, {elem(hd(rows), 0), elem(hd(rows), 1) - 1})
388 decrease_limit_iter(table, tl(rows))
389 end
390 end
391
392 defp decrease_limit() do
393 select = [{{:"$1", :"$2"}, [{:>, :"$2", 0}], [{{:"$1", :"$2"}}]}]
394 decrease_limit_iter(:registrations, :ets.select(:registrations, select))
395 decrease_limit_iter(:account_attempts, :ets.select(:account_attempts, select))
396 decrease_limit_iter(:ip_attempts, :ets.select(:ip_attempts, select))
397 :timer.sleep(30000)
398 decrease_limit()
399 end
400
401 def start() do
402 :users = :ets.new(:users, [:set, :public, :named_table])
403 :registrations = :ets.new(:registrations, [:set, :public, :named_table])
404 :ip_attempts = :ets.new(:ip_attempts, [:set, :public, :named_table])
405 :account_attempts = :ets.new(:account_attempts, [:set, :public, :named_table])
406 :messages_rate = :ets.new(:messages_rate, [:set, :public, :named_table])
407 Gmi.init()
408 Gmi.add_route("/", fn args -> main_page(args) end)
409 Gmi.add_route("/register", fn args -> ask_input(args, "Username", "/register/") end)
410 Gmi.add_route("/register/:name", fn args -> register_complete(args) end)
411 Gmi.add_route("/register/x/success", fn _ ->
412 Gmi.content(
413 "# Registration complete\n\n" <>
414 "=>/login You can now login with your account\n")
415 end)
416 Gmi.add_route("/login", fn args -> ask_input(args, "Username", "/login/") end)
417 Gmi.add_route("/login/:name", fn args -> login_complete(args) end)
418 Gmi.add_route("/account", fn args ->
419 connected(args, fn args, user ->
420 account(args, user)
421 end)
422 end)
423 Gmi.add_route("/account/write", fn args ->
424 connected(args, fn args, user ->
425 account_write(args, user)
426 end)
427 end)
428 Gmi.add_route("/account/zone", fn args ->
429 connected(args, fn args, user ->
430 account_zone(args, user)
431 end)
432 end)
433 Gmi.add_route("/account/llength", fn args ->
434 connected(args, fn args, user ->
435 account_line_length(args, user)
436 end)
437 end)
438 Gmi.add_route("/account/margin", fn args ->
439 connected(args, fn args, user ->
440 account_left_margin(args, user)
441 end)
442 end)
443 Gmi.add_route("/account/dm", fn args ->
444 connected(args, fn args, _ ->
445 ask_input(args, "Username", "/account/dm/")
446 end)
447 end)
448 Gmi.add_route("/account/dm/:name", fn args ->
449 connected(args, fn args, user ->
450 dm(args, user)
451 end)
452 end)
453 Gmi.add_route("/account/dm/:name/write", fn args ->
454 connected(args, fn args, user ->
455 dm_write(args, user)
456 end)
457 end)
458 Gmi.add_route("/account/contacts", fn args ->
459 connected(args, fn args, user ->
460 contacts(args, user)
461 end)
462 end)
463 Gmi.add_route("/account/disconnect", fn args ->
464 if args[:cert] != nil do
465 :ets.delete(:users, args[:cert])
466 end
467 Gmi.redirect("/")
468 end)
469 spawn_link(fn -> decrease_limit() end)
470 Gmi.listen()
471 end
472
473 end
474