Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: introduce rate limiting for audio codes #314 #316

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
4 changes: 4 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ config :qrstorage, Qrstorage.Services.Vault,
config :qrstorage,
translation_transition_date: System.get_env("QR_CODE_TRANSLATION_TRANSITION_DATE", "2024-07-09 00:00:00")

# configure rate limiting:
config :qrstorage,
rate_limiting_character_limit: String.to_integer(System.get_env("QR_CODE_RATE_LIMITING_CHARACTER_LIMIT", "25000"))

# from mix phx.gen.release
if System.get_env("PHX_SERVER") do
config :qrstorage, QrstorageWeb.Endpoint, server: true
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ services:
OBJECT_STORAGE_BUCKET: qrstorage-dev
QR_CODE_DEFAULT_LOCALE: "de"
QR_CODE_TRANSLATION_TRANSITION_DATE: "2024-07-09 00:00:00"
QR_CODE_RATE_LIMITING_CHARACTER_LIMIT: "25000"
DEEPL_API_KEY: ""
READSPEAKER_API_KEY: ""
# Replace this in production with your own key!
Expand Down
19 changes: 19 additions & 0 deletions lib/qrstorage/qr_codes.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ defmodule Qrstorage.QrCodes do

alias Qrstorage.QrCodes.QrCode

require Logger

@doc """
Gets a single qr_code.

Expand Down Expand Up @@ -85,4 +87,21 @@ defmodule Qrstorage.QrCodes do
def update_last_accessed_at(qr_code) do
Repo.update!(QrCode.changeset_with_upated_last_accessed_at(qr_code))
end

def audio_character_count_in_last_hours(hours) do
# this query SUMs the length of the text of all audio codes within the last ^hour:
character_count_query =
from q in QrCode,
where: q.content_type == :audio and q.inserted_at > ago(^hours, "hour"),
select: sum(fragment("character_length(?)", q.text))

case Repo.one!(character_count_query) do
nil ->
Logger.warning("Character length for audio characters is nil!")
0

result ->
result
end
end
end
13 changes: 9 additions & 4 deletions lib/qrstorage/services/qr_code_service.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ defmodule Qrstorage.Services.QrCodeService do
alias Qrstorage.Services.RecordingService
alias Qrstorage.Services.StorageService
alias Qrstorage.Services.TtsService
alias Qrstorage.Services.RateLimitingService

import QrstorageWeb.Gettext

Expand Down Expand Up @@ -44,10 +45,14 @@ defmodule Qrstorage.Services.QrCodeService do
end

defp handle_audio_qr_code(%QrCode{} = qr_code) do
# always add translation and get audio:
case add_translation(qr_code) do
{:error, error_message} -> {:error, error_message}
{:ok, qr_code_with_translation} -> add_tts(qr_code_with_translation)
if RateLimitingService.allow?(qr_code) do
# always add translation and get audio:
case add_translation(qr_code) do
{:error, error_message} -> {:error, error_message}
{:ok, qr_code_with_translation} -> add_tts(qr_code_with_translation)
end
else
{:error, gettext("Rate limit reached.")}
end
end

Expand Down
32 changes: 32 additions & 0 deletions lib/qrstorage/services/rate_limiting_service.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
defmodule Qrstorage.Services.RateLimitingService do
require Logger

alias Qrstorage.QrCodes
alias Qrstorage.QrCodes.QrCode

def allow?(%QrCode{} = qr_code) do
existing_character_count = audio_character_count_in_timeframe(qr_code)

if existing_character_count <= character_limit() do
true
else
Logger.warning(
"Rate Limit reached: QR-Code Text length: #{String.length(qr_code.text)} - Created within timeframe including QR-Code-Text-Length: #{existing_character_count} - Character Limit: #{character_limit()}."
)

false
end
end

defp audio_character_count_in_timeframe(qr_code) do
# We start with 24 - we can make this configurable later
hours = 24
past_audio_character_count = QrCodes.audio_character_count_in_last_hours(hours)
String.length(qr_code.text) + past_audio_character_count
end

defp character_limit() do
# load from config:
Application.get_env(:qrstorage, :rate_limiting_character_limit)
end
end
13 changes: 9 additions & 4 deletions priv/gettext/de/LC_MESSAGES/default.po
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,8 @@ msgstr "Fehler beim Upload der Audio-Datei."
msgid "Error while extracting audio file from plug: Invalid content type"
msgstr "Fehler beim Upload der Audio-Datei."

#: lib/qrstorage/services/qr_code_service.ex:80
#: lib/qrstorage/services/qr_code_service.ex:84
#: lib/qrstorage/services/qr_code_service.ex:85
#: lib/qrstorage/services/qr_code_service.ex:89
#, elixir-autogen, elixir-format
msgid "Qr code recording not extracted"
msgstr "Fehler beim Upload der Audio-Datei."
Expand Down Expand Up @@ -260,8 +260,8 @@ msgstr "Die Seite konnte nicht gefunden werden."
msgid "QR-Code has been deleted?"
msgstr "QR-Codes werden nach Inaktivität und Ablauf der ausgewählten Speicherdauer gelöscht."

#: lib/qrstorage/services/qr_code_service.ex:97
#: lib/qrstorage/services/qr_code_service.ex:101
#: lib/qrstorage/services/qr_code_service.ex:102
#: lib/qrstorage/services/qr_code_service.ex:106
#, elixir-autogen, elixir-format
msgid "Qr code tts not stored"
msgstr ""
Expand All @@ -280,3 +280,8 @@ msgstr "Aufnahmen werden nach 30 Tagen Inaktivität gelöscht."
#, elixir-autogen, elixir-format, fuzzy
msgid "Text was automatically translated by DeepL."
msgstr "Der Text wurde automatisch übersetzt von DeepL."

#: lib/qrstorage/services/qr_code_service.ex:55
#, elixir-autogen, elixir-format
msgid "Rate limit reached."
msgstr "Derzeit können keine QR-Codes erstellt werden. Bitte versuch es später erneut."
13 changes: 9 additions & 4 deletions priv/gettext/default.pot
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,8 @@ msgstr ""
msgid "Error while extracting audio file from plug: Invalid content type"
msgstr ""

#: lib/qrstorage/services/qr_code_service.ex:80
#: lib/qrstorage/services/qr_code_service.ex:84
#: lib/qrstorage/services/qr_code_service.ex:85
#: lib/qrstorage/services/qr_code_service.ex:89
#, elixir-autogen, elixir-format
msgid "Qr code recording not extracted"
msgstr ""
Expand Down Expand Up @@ -259,8 +259,8 @@ msgstr ""
msgid "QR-Code has been deleted?"
msgstr ""

#: lib/qrstorage/services/qr_code_service.ex:97
#: lib/qrstorage/services/qr_code_service.ex:101
#: lib/qrstorage/services/qr_code_service.ex:102
#: lib/qrstorage/services/qr_code_service.ex:106
#, elixir-autogen, elixir-format
msgid "Qr code tts not stored"
msgstr ""
Expand All @@ -279,3 +279,8 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Text was automatically translated by DeepL."
msgstr ""

#: lib/qrstorage/services/qr_code_service.ex:55
#, elixir-autogen, elixir-format
msgid "Rate limit reached."
msgstr ""
13 changes: 9 additions & 4 deletions priv/gettext/en/LC_MESSAGES/default.po
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,8 @@ msgstr ""
msgid "Error while extracting audio file from plug: Invalid content type"
msgstr ""

#: lib/qrstorage/services/qr_code_service.ex:80
#: lib/qrstorage/services/qr_code_service.ex:84
#: lib/qrstorage/services/qr_code_service.ex:85
#: lib/qrstorage/services/qr_code_service.ex:89
#, elixir-autogen, elixir-format
msgid "Qr code recording not extracted"
msgstr ""
Expand Down Expand Up @@ -260,8 +260,8 @@ msgstr ""
msgid "QR-Code has been deleted?"
msgstr ""

#: lib/qrstorage/services/qr_code_service.ex:97
#: lib/qrstorage/services/qr_code_service.ex:101
#: lib/qrstorage/services/qr_code_service.ex:102
#: lib/qrstorage/services/qr_code_service.ex:106
#, elixir-autogen, elixir-format
msgid "Qr code tts not stored"
msgstr ""
Expand All @@ -280,3 +280,8 @@ msgstr ""
#, elixir-autogen, elixir-format, fuzzy
msgid "Text was automatically translated by DeepL."
msgstr "Text was automatically translated by DeepL."

#: lib/qrstorage/services/qr_code_service.ex:55
#, elixir-autogen, elixir-format
msgid "Rate limit reached."
msgstr ""
29 changes: 29 additions & 0 deletions test/qrstorage/qr_codes_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ defmodule Qrstorage.QrCodesTest do

alias Qrstorage.QrCodes

import ExUnit.CaptureLog

describe "qrcodes" do
alias Qrstorage.QrCodes.QrCode

Expand Down Expand Up @@ -213,4 +215,31 @@ defmodule Qrstorage.QrCodesTest do
assert {:error, %Ecto.Changeset{}} = QrCodes.create_qr_code(invalid_text_attrs)
end
end

describe "audio_character_count_in_last_hours/1" do
test "audio_character_count_in_last_hours/1 without existing audio codes returns 0" do
{result, logs} = with_log(fn -> QrCodes.audio_character_count_in_last_hours(1) end)

assert result == 0
assert logs =~ "Character length for audio characters is nil"
end

test "audio_character_count_in_last_hours/1 with multiple codes return correct value" do
qr_code_with_insertion_date(@valid_audio_attrs, Timex.shift(Timex.now(), hours: -2))
qr_code_with_insertion_date(@valid_audio_attrs, Timex.shift(Timex.now(), hours: -1))
QrCodes.create_qr_code(@valid_audio_attrs)

assert QrCodes.audio_character_count_in_last_hours(3) == String.length(@valid_audio_attrs.text) * 3
assert QrCodes.audio_character_count_in_last_hours(2) == String.length(@valid_audio_attrs.text) * 2
assert QrCodes.audio_character_count_in_last_hours(1) == String.length(@valid_audio_attrs.text)
end

test "audio_character_count_in_last_hours/1 only counts audio codes" do
qr_code_with_insertion_date(@valid_audio_attrs, Timex.shift(Timex.now(), hours: -1))
qr_code_with_insertion_date(@valid_link_attrs, Timex.shift(Timex.now(), hours: -1))
qr_code_with_insertion_date(@valid_attrs, Timex.shift(Timex.now(), hours: -1))

assert QrCodes.audio_character_count_in_last_hours(2) == String.length(@valid_audio_attrs.text)
end
end
end
19 changes: 18 additions & 1 deletion test/qrstorage/services/qr_code_service_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ defmodule Qrstorage.Services.QrCodeServiceTest do
use Qrstorage.DataCase
use Qrstorage.ApiCase
use Qrstorage.StorageCase
use Qrstorage.RateLimitingCase

alias Qrstorage.Services.QrCodeService

Expand Down Expand Up @@ -153,11 +154,27 @@ defmodule Qrstorage.Services.QrCodeServiceTest do
assert logs =~ "deleting qr code after creation"
assert qr_code_count_start == qr_code_count_end
end

test "create_qr_code/1 does not create code when rate limit has been reached" do
qr_code_count_start = qr_code_count()

{{status, _changeset, _message}, logs} =
with_log(fn ->
with_rate_limit_set_to(1, fn -> QrCodeService.create_qr_code(@tts_attrs) end)
end)

qr_code_count_end = qr_code_count()

assert status == :error
assert logs =~ "Rate Limit reached"
assert logs =~ "deleting qr code after creation"
assert qr_code_count_start == qr_code_count_end
end
end

describe "create_qr_code/1 for recording codes" do
@tag :tmp_dir
test "create_qr_code/1 returns :ok for tts codes", %{tmp_dir: tmp_dir} do
test "create_qr_code/1 returns :ok for recording codes", %{tmp_dir: tmp_dir} do
# create dummy file for test:
file_path = writeTmpFile(tmp_dir)

Expand Down
72 changes: 72 additions & 0 deletions test/qrstorage/services/rate_limiting_service_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
defmodule Qrstorage.Services.RateLimitingServiceTest do
use Qrstorage.DataCase
use Qrstorage.StorageCase
use Qrstorage.RateLimitingCase

alias Qrstorage.QrCodes.QrCode
alias Qrstorage.Services.RateLimitingService

describe "allow?/1 without existing audio qr codes" do
test "allow?/1 returns false when the text is longer than the limit" do
qr_code = qr_code_with_text_length(5)

{result, logs} = with_log_and_rate_limit_set_to(1, fn -> RateLimitingService.allow?(qr_code) end)

assert result == false
assert logs =~ "Rate Limit reached"
end

test "allow?/1 returns true when the text is shorter than the limit" do
qr_code = qr_code_with_text_length(5)

{result, _logs} = with_log_and_rate_limit_set_to(15, fn -> RateLimitingService.allow?(qr_code) end)

assert result == true
end

test "allow?/1 returns true when the text is equal to the limit" do
qr_code = qr_code_with_text_length(5)

{result, _logs} = with_log_and_rate_limit_set_to(5, fn -> RateLimitingService.allow?(qr_code) end)

assert result == true
end
end

describe "allow?/1 with existing audio qr codes" do
setup do
audio_qr_code_fixture(%{text: "12345"})
:ok
end

test "allow?/1 returns false when the text is longer than the limit and the existing character count" do
qr_code = qr_code_with_text_length(2)

{result, logs} = with_log_and_rate_limit_set_to(6, fn -> RateLimitingService.allow?(qr_code) end)

assert result == false
assert logs =~ "Rate Limit reached"
end

test "allow?/1 returns true when the text is shorter than the limit and the existing character count" do
qr_code = qr_code_with_text_length(2)

{result, _logs} = with_log_and_rate_limit_set_to(8, fn -> RateLimitingService.allow?(qr_code) end)

assert result == true
end

test "allow?/1 returns true when the text is equal to the limit and the existing character count" do
qr_code = qr_code_with_text_length(2)

{result, _logs} = with_log_and_rate_limit_set_to(7, fn -> RateLimitingService.allow?(qr_code) end)

assert result == true
end
end

defp qr_code_with_text_length(length) do
text = String.duplicate("a", length)
%QrCode{text: text}
end
end
7 changes: 7 additions & 0 deletions test/support/data_case.ex
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ defmodule Qrstorage.DataCase do
Repo.update!(Ecto.Changeset.cast(qr_code, %{last_accessed_at: last_access_date}, [:last_accessed_at]))
qr_code
end

def qr_code_with_insertion_date(attrs \\ %{}, insertion_date) do
attrs = Map.merge(@valid_attrs, attrs)
qr_code = qr_code_fixture(attrs)
Repo.update!(Ecto.Changeset.cast(qr_code, %{inserted_at: insertion_date}, [:inserted_at]))
qr_code
end
end
end

Expand Down
Loading