Skip to content

Commit

Permalink
feat: handle_ARTICLE/2 callback
Browse files Browse the repository at this point in the history
Handles "ARTICLE" command with message ID argument

The callback should return `{Number, Article}` or `false` if no article.

Article is a `{MessageId, Headers, Body}`.

If article found, responds with "220". Else, "430".

If the server has no "READER" capability, also responds with "430".
  • Loading branch information
sntran committed Mar 3, 2021
1 parent a731c77 commit 10bf398
Show file tree
Hide file tree
Showing 5 changed files with 204 additions and 6 deletions.
6 changes: 6 additions & 0 deletions include/gen_nntp.hrl
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@
-type on_start() :: {'ok', pid()} | 'ignore' | {'error', {'already_started', pid()} | term()}.
-type address() :: inet:socket_address() | inet:hostname() | binary().
-type port_number() :: inet:port_number().
-type message_id() :: binary().
-type article() :: {
message_id(),
Headers :: map(),
Body :: binary()
}.
9 changes: 8 additions & 1 deletion lib/gen_nntp.ex
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,18 @@ defmodule GenNNTP do
{:error, reason :: String.t(), state}
when group: String.t()

@callback handle_ARTICLE(arg, state) ::
{:ok, { number, :gen_nttp.article()}, state }|
{:ok, false, state} |
{:error, reason :: String.t(), state}
when number: non_neg_integer(),
arg: :gen_nttp.message_id() | number

@callback handle_command(command :: String.t(), state) ::
{:reply, response :: any(), state} |
{:noreply, state} |
{:stop, reason :: any(), state} |
{:stop, reason :: any(), response :: any(), state}

@optional_callbacks handle_GROUP: 2
@optional_callbacks handle_GROUP: 2, handle_ARTICLE: 2
end
57 changes: 55 additions & 2 deletions src/gen_nntp.erl
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
-export_type([
name/0,
option/0,
on_start/0
on_start/0,
message_id/0,
article/0
]).

-include("gen_nntp.hrl").
Expand Down Expand Up @@ -64,6 +66,7 @@
| {stop, Reason :: any()}.

-callback handle_CAPABILITIES(state()) -> {ok, Capabilities :: [binary()], state()}.

-callback handle_GROUP(Group, state()) ->
{ok, {
Group, Number :: non_neg_integer(),
Expand All @@ -74,13 +77,20 @@
| {error, Reason :: binary(), state()}
when Group :: binary().

-callback handle_ARTICLE(Arg, state()) ->
{ok, { Number, article()}, state()}
| {ok, false, state()}
| {error, Reason :: binary(), state()}
when Number :: non_neg_integer(),
Arg :: message_id() | Number.

-callback handle_command(Command :: binary(), state()) ->
{reply, Response :: binary(), state()}
| {noreply, state()}
| {stop, Reason :: any(), state()}
| {stop, Reason :: any, Response :: binary(), state()}.

-optional_callbacks([handle_GROUP/2]).
-optional_callbacks([handle_GROUP/2, handle_ARTICLE/2]).

%% ==================================================================
%% API
Expand Down Expand Up @@ -250,6 +260,49 @@ handle_info({tcp, Socket, <<"CAPABILITIES\r\n">>}, Client) ->

{noreply, Client#client{state = State1}};

% Client requests for an article by message ID.
handle_info({tcp, Socket, <<"ARTICLE ", ArgCRLF/binary>>}, Client) ->
% Removes the CRLF pair.
Arg = string:chomp(ArgCRLF),
#client{transport = Transport, module = Module, state = State} = Client,
% Asks the callback module to provide the capacitities at this moment.
{ok, Capabilities, State1} = Module:handle_CAPABILITIES(State),

State2 = case lists:member(<<"READER">>, Capabilities) of
true ->
{ok, ArticleInfo, NewState} = Module:handle_ARTICLE(Arg, State1),

Response = case ArticleInfo of
% Article exists
{Number, {Id, Headers, Body}} ->

join(<<"\r\n">>, [
join(<<" ">>, [<<"220">>, integer_to_binary(Number), Id]),
maps:fold(fun(Header, Content, AccIn) ->
<<AccIn/binary, Header/binary, ": ", Content/binary, "\r\n">>
end, <<"">>, Headers),
Body,
<<".">>
]);
% No article
false ->
<<"430 No article with that message-id">>
end,

Transport:send(Socket, <<Response/binary, "\r\n">>),
NewState;
false ->
Transport:send(Socket, <<"430 No article with that message-id\r\n">>),
State1
end,

% Ready for the next command.
ok = Transport:setopts(Socket, [{active, once}]),

{noreply, Client#client{state = State2}};

% Client selects a newsgroup as the currently selected newsgroup and returns
% summary information about it with 211 code, or 411 if not available.
handle_info({tcp, Socket, <<"GROUP ", GroupCRLF/binary>>}, Client) ->
% Removes the CRLF pair.
Group = string:chomp(GroupCRLF),
Expand Down
125 changes: 122 additions & 3 deletions test/gen_nntp_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,6 @@ defmodule GenNNTPTest do
test "responds with `211 number low high group` when the client asks for it", %{socket: socket} do
:ok = :gen_tcp.send(socket, "GROUP misc.test\r\n")
{:ok, response} = :gen_tcp.recv(socket, 0, 1000)
# This is unclear to me, as the specs say nothing about this case.
assert response =~ ~r/^211 0 0 0 misc\.test/
end

Expand All @@ -306,7 +305,7 @@ defmodule GenNNTPTest do
refute_receive(
{:called_back, :handle_GROUP, 2},
100,
"@callback handle_GROUP/2 should not be called when the server has no READ capability"
"@callback handle_GROUP/2 should not be called when the server has no READER capability"
)
end

Expand All @@ -322,12 +321,132 @@ defmodule GenNNTPTest do
test "responds with 411 when the group specified is not available", %{socket: socket} do
:ok = :gen_tcp.send(socket, "GROUP example.is.sob.bradner.or.barber\r\n")
{:ok, response} = :gen_tcp.recv(socket, 0, 1000)
# @sntran: This is unclear to me, as the specs say nothing about this case.
assert response =~ ~r/^411 /
end

end

describe "@callback handle_ARTICLE/2" do

setup context do
# Default to have "READER" capability for "ARTICLE" to work.
capabilities = context[:capabilities] || ["READER"]
articles = context[:articles] || [
{
"<45223423@example.com>", # message ID.
# Headers
%{
"Path" => "pathost!demo!whitehouse!not-for-mail",
"From" => "'Demo User' <nobody@example.net>",
"Newsgroups" => "misc.test",
"Subject" => "I am just a test article",
"Date" => "6 Oct 1998 04:38:40 -0500",
"Organization" => "An Example Net, Uncertain, Texas",
"Message-ID" => "<45223423@example.com>"
},
# Body
"This is just a test article."
}
]

TestNNTPServer.start(
handle_CAPABILITIES: fn(state) ->
{:ok, capabilities, state}
end,
handle_ARTICLE: fn(message_id, state) ->
Kernel.send(:tester, {:called_back, :handle_ARTICLE, 2})

case List.keyfind(articles, message_id, 0, false) do
false ->
{:ok, false, state}
article ->
{:ok, {0, article}, state}
end
end
)

{:ok, socket, _greeting} = GenNNTP.connect()

%{socket: socket, articles: articles}
end

test "is called when the client asks for it", %{socket: socket} do
refute_receive(
{:called_back, :handle_ARTICLE, 2},
100,
"@callback handle_ARTICLE/2 should not be called when client has not asked for it"
)

:ok = :gen_tcp.send(socket, "ARTICLE <45223423@example.com>\r\n")

assert_receive(
{:called_back, :handle_ARTICLE, 2},
100,
"@callback handle_ARTICLE/2 was not called"
)
end

test "responds with `220 number message_id article` when the client asks for it", context do
%{socket: socket, articles: articles} = context

message_id = "<45223423@example.com>"
:ok = :gen_tcp.send(socket, "ARTICLE #{message_id}\r\n")

# The response code with number and message_id
{:ok, response} = :gen_tcp.recv(socket, 0, 1000)
assert response === "220 0 #{message_id}\r\n"

{^message_id, headers, body} = List.keyfind(articles, message_id, 0, false)

# Headers, one per line.
Enum.each(headers, fn({header, content}) ->
{:ok, response} = :gen_tcp.recv(socket, 0, 1000)
assert response === "#{header}: #{content}\r\n"
end)

# Then an empty line
{:ok, response} = :gen_tcp.recv(socket, 0, 1000)
assert response === "\r\n"

# Then the body
{:ok, response} = :gen_tcp.recv(socket, 0, 1000)
assert response === "#{body}\r\n"

# Then the termination line
{:ok, response} = :gen_tcp.recv(socket, 0, 1000)
assert response === ".\r\n"

end

# The setup sets a list of capabilities with "READER" by default, so we empty it here.
@tag capabilities: []
test "is not called when there is no READER capability", %{socket: socket} do
:ok = :gen_tcp.send(socket, "ARTICLE <45223423@example.com>\r\n")

refute_receive(
{:called_back, :handle_ARTICLE, 2},
100,
"@callback handle_ARTICLE/2 should not be called when the server has no READER capability"
)
end

# The setup sets a list of capabilities with "READER" by default, so we empty it here.
@tag capabilities: []
test "responds with 430 when there is no READER capability", %{socket: socket} do
:ok = :gen_tcp.send(socket, "ARTICLE <45223423@example.com>\r\n")
{:ok, response} = :gen_tcp.recv(socket, 0, 1000)
# @sntran: This is unclear to me, as the specs say nothing about this case.
assert response =~ ~r/^430 /
end

test "responds with 430 when the article specified is not available", %{socket: socket} do
:ok = :gen_tcp.send(socket, "ARTICLE <i.am.not.there@example.com>\r\n")
{:ok, response} = :gen_tcp.recv(socket, 0, 1000)
assert response =~ ~r/^430 /
end

end

describe "server interaction" do
setup do
TestNNTPServer.start()
Expand Down
13 changes: 13 additions & 0 deletions test/support/test_nntp_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,19 @@ defmodule TestNNTPServer do
end
end

@impl GenNNTP
def handle_ARTICLE(arg, client) do
state = client[:state]

case maybe_apply(client, :handle_ARTICLE, [arg, state], {:ok, {0, {arg, %{}, ""}}, state}) do
{:ok, article_info, state} ->
client = Keyword.put(client, :state, state)
{:ok, article_info, client}
other ->
other
end
end

@impl GenNNTP
def handle_command(_command, state) do
{:noreply, state}
Expand Down

0 comments on commit 10bf398

Please sign in to comment.