├── .formatter.exs ├── .github └── workflows │ └── elixir.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config └── config.exs ├── examples └── nserv.exs ├── include ├── README.md └── gen_nntp.hrl ├── lib └── gen_nntp.ex ├── mix.exs ├── mix.lock ├── src ├── README.md ├── gen_nntp.erl └── gen_nntp_internal.hrl └── test ├── gen_nntp_test.exs ├── support ├── test_case.ex └── test_nntp_server.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: push 4 | 5 | jobs: 6 | test: 7 | 8 | runs-on: ${{ matrix.os }} 9 | name: OTP ${{ matrix.otp }} | Elixir ${{ matrix.elixir }} | OS ${{ matrix.os }} 10 | 11 | strategy: 12 | matrix: 13 | os: ['ubuntu-18.04', 'ubuntu-20.04'] 14 | otp: ['23.x'] 15 | elixir: ['1.11.x'] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Elixir 20 | uses: erlef/setup-elixir@v1 21 | with: 22 | elixir-version: ${{matrix.elixir}} # Define the elixir version [required] 23 | otp-version: ${{matrix.otp}} # Define the OTP version [required] 24 | - name: Restore dependencies cache 25 | uses: actions/cache@v2 26 | with: 27 | path: deps 28 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 29 | restore-keys: ${{ runner.os }}-mix- 30 | - name: Install dependencies 31 | run: mix deps.get 32 | - name: Run tests 33 | run: mix test 34 | env: 35 | PORT: 6791 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | gen_nntp-*.tar 24 | 25 | 26 | # Temporary files for e.g. tests 27 | /tmp 28 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 0.18.0 (2021-03-12) 4 | 5 | ### Features 6 | 7 | * "LAST" command ([f0e2d4a](https://github.com/sntran/gen_nntp/commit/f0e2d4a22155185753a2c920ea1d9da5c83be0bf)) 8 | * `[@callback](https://github.com/callback) handle_CAPABILITIES/1` ([4a12906](https://github.com/sntran/gen_nntp/commit/4a129068348f61864ba9cf2d7ee9c74d25577cfc)) 9 | * `connect/3` to connect to NNTP server ([a5b850a](https://github.com/sntran/gen_nntp/commit/a5b850a4a76ff44c6d3e79151b60673020aa0b7b)) 10 | * `handle_ARTICLE/2` callback ([10bf398](https://github.com/sntran/gen_nntp/commit/10bf398ac0307ce1ba7e1180a6b829f4f7421e48)) 11 | * `handle_GROUP` ([87bd933](https://github.com/sntran/gen_nntp/commit/87bd933be169dc2e8c04f968d1f4328f41895527)) 12 | * `HELP` command ([ceaa051](https://github.com/sntran/gen_nntp/commit/ceaa051bbc4af37cd539ab0ce901b9e021870f0d)) 13 | * all cases of ARTICLE and GROUP ([1587252](https://github.com/sntran/gen_nntp/commit/15872523505a5362150a1dd2fca588a1084a44a5)) 14 | * command with multi-line response ([8bd780f](https://github.com/sntran/gen_nntp/commit/8bd780f56a1fe15da7625820c30833fe21a88cdb)) 15 | * handle QUIT command ([295e545](https://github.com/sntran/gen_nntp/commit/295e545dcaa73d430581138fe5689a3665bbc156)) 16 | * handle VERSION capability ([484a8b6](https://github.com/sntran/gen_nntp/commit/484a8b65b1e570789b420f1c8d75c92b89c60017)) 17 | * handle_LISTGROUP/2 ([99fe433](https://github.com/sntran/gen_nntp/commit/99fe4337cb7b02aacf8ea65b2900235214a40ede)) 18 | * HEAD, BODY and STAT commands ([73f3be7](https://github.com/sntran/gen_nntp/commit/73f3be77ca883ea22f8a986712b8556872c9bb77)) 19 | * initial server behaviour ([84a396d](https://github.com/sntran/gen_nntp/commit/84a396d035b69f152f1e0f195cdfaeca4620fde2)) 20 | * make handle_command/2 optional ([c184f73](https://github.com/sntran/gen_nntp/commit/c184f73526a04229c906a33fee3a550e22c10794)) 21 | * NEXT command ([77f8d26](https://github.com/sntran/gen_nntp/commit/77f8d26831ee7a99a64f78ad07a50079b566adf2)) 22 | * port from env variable ([682acf8](https://github.com/sntran/gen_nntp/commit/682acf8ddf9a3586fc4a4632e0dd2d9aa9ecd0a8)) 23 | * send/3 to sends to a NNTP socket ([8ad00ec](https://github.com/sntran/gen_nntp/commit/8ad00ec428a01733a432f8e65534cff1159e85eb)) 24 | * NServ example ([1a98824](https://github.com/sntran/gen_nntp/commit/1a9882448b7d1e2de8ce7e04a7ebd8c4137fbd2a)) 25 | 26 | ### Bug Fixes 27 | 28 | * error codes for NEXT and LAST ([5414832](https://github.com/sntran/gen_nntp/commit/5414832c1ce54e2243ffcfd75e7ca1b2035cd919)) 29 | * HEAD, BODY and STAT callbacks ([35511a7](https://github.com/sntran/gen_nntp/commit/35511a7af9b7e9171f7a170185d4a6918a75a1e8)) 30 | 31 | * `send/3` becomes `command/3` ([94b0820](https://github.com/sntran/gen_nntp/commit/94b08204e39d5128dbae60ea995446dd29d5fc08)) 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gen_nntp 2 | 3 | [![CI](https://github.com/sntran/gen_nntp/actions/workflows/elixir.yml/badge.svg)](https://github.com/sntran/gen_nntp/actions/workflows/elixir.yml) 4 | [![Hex Version](https://img.shields.io/hexpm/v/gen_nntp.svg)](https://hex.pm/packages/gen_nntp) 5 | [![License](https://img.shields.io/github/license/sntran/gen_nntp.svg)](https://choosealicense.com/licenses/apache-2.0/) 6 | 7 | The Erlang NNTP client and server library. 8 | 9 | ## Installation 10 | 11 | The package can be installed by adding `gen_nntp` to your list of dependencies in `mix.exs`: 12 | 13 | ```elixir 14 | def deps do 15 | [ 16 | {:gen_nntp, "~> 0.21.0"} 17 | ] 18 | end 19 | ``` 20 | 21 | Documentation can be found at [https://hexdocs.pm/gen_nntp](https://hexdocs.pm/gen_nntp). 22 | 23 | ## NNTP Protocols 24 | 25 | ### Commands 26 | 27 | - [x] CAPABILITIES 28 | - [x] HEAD 29 | - [x] HELP 30 | - [x] QUIT 31 | - [x] STAT 32 | - [ ] HDR 33 | - [ ] LIST HEADERS 34 | - [ ] IHAVE 35 | - [ ] LIST 36 | - [ ] LIST ACTIVE 37 | - [ ] LIST ACTIVE.TIMES 38 | - [ ] LIST DISTRIB.PATS 39 | - [ ] LIST NEWSGROUPS 40 | - [ ] MODE READER 41 | - [ ] NEWNEWS 42 | - [ ] OVER 43 | - [ ] LIST OVERVIEW.FMT 44 | - [x] POST 45 | - [x] ARTICLE 46 | - [x] BODY 47 | - [x] DATE 48 | - [x] GROUP 49 | - [x] LAST 50 | - [x] LISTGROUP 51 | - [ ] NEWGROUPS 52 | - [x] NEXT 53 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :logger, :console, 4 | format: "$time $metadata[$level] $levelpad$message\n" 5 | 6 | if Mix.env == :dev do 7 | config :mix_test_watch, 8 | clear: true 9 | end 10 | -------------------------------------------------------------------------------- /examples/nserv.exs: -------------------------------------------------------------------------------- 1 | # Usage: mix run examples/nserv.exs -d [optional switches] 2 | # -d - directory whose files will be served 3 | # Optional switches: 4 | # -b
- ip address to bind to (default is 0.0.0.0) 5 | # -p - port number for the first instance (default is 6791) 6 | # 7 | # Hit Ctrl+C twice to stop it. 8 | 9 | require Logger 10 | 11 | defmodule NServ do 12 | @moduledoc """ 13 | A simple NNTP server that serves content from a directory. 14 | 15 | Clients can request articles that are parts of a file using a specific format 16 | for message-id. See `c:handle_ARTICLE/2` for that format. 17 | """ 18 | @behaviour GenNNTP 19 | 20 | @regex ~r/<([\w\/.-]+)\?(\d+)=(\d+):(\d+)>/ 21 | 22 | @defaults [port: 6791] 23 | 24 | def child_spec(arg) do 25 | %{ 26 | id: __MODULE__, 27 | start: {__MODULE__, :start, arg} 28 | } 29 | end 30 | 31 | def start(datadir, options \\ []) do 32 | options = Keyword.merge(@defaults, options) 33 | Logger.info("Listening on port #{options[:port]}") 34 | GenNNTP.start(__MODULE__, datadir, options) 35 | end 36 | 37 | # Callbacks 38 | 39 | @impl true 40 | def init(datadir) do 41 | Logger.info("[S] Incoming connection") 42 | {:ok, datadir} 43 | end 44 | 45 | @doc """ 46 | Returns an article based on its `message_id`. 47 | 48 | In NServ `message_id` has a special format which includes information about 49 | file and portion of the file to be returned: 50 | 51 | `ARTICLE ` 52 | 53 | where: 54 | 55 | - `xxx` - part number (integer) 56 | - `yyy` - offset from which to read the files (integer) 57 | - `zzz` - size of file block to return (integer) 58 | """ 59 | @impl true 60 | def handle_ARTICLE(message_id, datadir) when is_binary(message_id) do 61 | Logger.info("[S] Received: ARTICLE #{message_id}") 62 | [_, file_path, xxx, yyy, zzz] = Regex.run(@regex, message_id) 63 | Logger.info("[S] Serving: #{message_id}") 64 | filename = Path.basename(file_path) 65 | 66 | Logger.info("[S] Sending segment #{filename} (#{xxx}=#{yyy}:#{zzz})") 67 | 68 | body = datadir 69 | |> Path.join(file_path) 70 | |> Path.absname 71 | |> File.stream!([], String.to_integer(zzz)) 72 | |> Enum.at(String.to_integer(xxx)) 73 | 74 | article = %{ 75 | id: message_id, 76 | headers: %{ 77 | "Message-ID" => message_id, 78 | "Subject" => filename 79 | }, 80 | body: body 81 | } 82 | 83 | {:ok, {0, article}, datadir} 84 | end 85 | 86 | @impl true 87 | def handle_CAPABILITIES(datadir) do 88 | {:ok, ["READER"], datadir} 89 | end 90 | 91 | @impl true 92 | def handle_HELP(datadir) do 93 | {:ok, "Self-serve", datadir} 94 | end 95 | 96 | end 97 | 98 | defmodule App do 99 | @moduledoc """ 100 | Your application entry-point. 101 | For actual applications, start/1 should be start/2. 102 | """ 103 | 104 | def start({data_dir, options}) do 105 | import Supervisor.Spec 106 | 107 | children = [ 108 | {NServ, [data_dir, options]}, 109 | ] 110 | 111 | Supervisor.start_link(children, strategy: :one_for_one) 112 | end 113 | end 114 | 115 | # Start the app and wait forever 116 | Logger.info("NServ 1.0 (Test NNTP server)") 117 | Logger.info("Press Ctrl+C to quit") 118 | 119 | {options, _argv, _invalid} = System.argv() 120 | |> OptionParser.parse( 121 | aliases: [d: :data_dir, b: :address, p: :port], 122 | strict: [data_dir: :string, address: :string, port: :integer] 123 | ) 124 | 125 | if options[:data_dir] do 126 | App.start(Keyword.pop(options, :data_dir)) 127 | 128 | Process.sleep(:infinity) 129 | end 130 | -------------------------------------------------------------------------------- /include/README.md: -------------------------------------------------------------------------------- 1 | # Includes 2 | 3 | The `include/` directory is used to store Erlang .hrl files that are to be included by other applications. 4 | -------------------------------------------------------------------------------- /include/gen_nntp.hrl: -------------------------------------------------------------------------------- 1 | -type name() :: atom() | {'local', term()} | {'global', term()} | {'via', module(), term()}. 2 | -type option() :: 3 | {'name', name()} 4 | | {'port', port_number()}. 5 | -type on_start() :: {'ok', pid()} | 'ignore' | {'error', {'already_started', pid()} | term()}. 6 | -type address() :: inet:socket_address() | inet:hostname() | binary(). 7 | -type port_number() :: inet:port_number(). 8 | -type message_id() :: binary(). 9 | -type headers() :: map(). 10 | -type body() :: binary(). 11 | -type article() :: #{ 12 | id := message_id(), 13 | headers => headers(), 14 | body => body() 15 | }. 16 | -------------------------------------------------------------------------------- /lib/gen_nntp.ex: -------------------------------------------------------------------------------- 1 | defmodule GenNNTP do 2 | @moduledoc ~S""" 3 | The NNTP client and server library. 4 | 5 | This module provides both the behaviour for an NNTP server, and the client 6 | API to interact with a NNTP server. 7 | 8 | All functionality is defined in `:gen_nntp`. This Elixir module is just a 9 | wrapper for `:gen_nntp`. 10 | 11 | ## Example 12 | 13 | The `GenNNTP` behaviour abstracts the common NNTP client-server interaction. 14 | Developers are only required to implement the callbacks and functionality 15 | they are interested in to respond to client's commands, per specs. 16 | 17 | Let's start with a code example and then explore the available callbacks. 18 | Imagine we want a NNTP server that keeps data in memory. 19 | 20 | defmodule InMemoryNNTP do 21 | @behaviour GenNNTP 22 | 23 | # Callbacks 24 | 25 | @impl true 26 | def init(data) do 27 | {:ok, data} 28 | end 29 | 30 | @impl true 31 | def handle_CAPABILITIES(data) do 32 | {:ok, ["READER", "POST"], data} 33 | end 34 | 35 | @impl true 36 | def handle_HELP(data) do 37 | {:ok, "This NNTP server only keeps data in memory.", data} 38 | end 39 | end 40 | 41 | # Start the server 42 | {:ok, pid} = GenNNTP.start(InMemoryNNTP, port: 6791) 43 | 44 | # This is the client 45 | {:ok, socket} = GenNNTP.connect("localhost", 6791) 46 | 47 | {:ok, response} = GenNNTP.command(socket, "CAPABILITIES") 48 | #=> {:ok, "101 Capability list:\r\nVERSION 2\r\nREADER\r\nPOST"} 49 | 50 | We start our `NServ` by calling `start/3`, passing the module 51 | with the server implementation and its initial argument (a path to a folder 52 | containing the data to serve from). We can primarily interact with the server 53 | by sending a command to the socket returned after connectiong to the server. 54 | 55 | Every time you do a `GenNNTP.command/2`, the client will send a command that 56 | must be handled by one of the callbacks defined in the GenNNTP, based on the 57 | issued command. There are many callbacks to be implemented when you use a 58 | `GenNNTP`. The required callbacks are `c:init/1`, `c:handle_CAPABILITIES/1` 59 | and `c:handle_HELP/1`. Other callbacks are optional in the sense that they are 60 | still required to be implemented when your server has the capability for them. 61 | For example, if your server has "READER" capability, you MUST provide these 62 | callbacks: `c:handle_GROUP/2`, `c:handle_LISTGROUP/2`, `c:handle_NEXT/2`, 63 | `c:handle_LAST/2`, `c:handle_ARTICLE/2`, `c:handle_HEAD/2`, `c:handle_BODY/2`, 64 | `c:handle_STAT/2`. Similarly, if your server has "POST" capability, you MUST 65 | provide `c:handle_POST/2` callback. 66 | 67 | ## Handling commands 68 | 69 | Our example advertises the "POST" capability, so we need to define a callback 70 | to handle the "POST" command from client. This aptly named `c:handle_POST/2` 71 | receives a map of type `t:article/0` and can decide to accept or reject it. 72 | 73 | Do note that `GenNNTP` abstracts away all the command parsing and response's 74 | codes, so that the callback only needs to, well, "handle" the corresponding 75 | argument, and returns an ok-tuple to accept or an error-tuple to reject. 76 | 77 | In our example NNTP server, we simply accept any article and store into our 78 | internal in-memory database. 79 | 80 | defmodule InMemoryNNTP do 81 | @behaviour GenNNTP 82 | 83 | # Callbacks 84 | 85 | @impl true 86 | def init(data) do 87 | {:ok, data} 88 | end 89 | 90 | @impl true 91 | def handle_CAPABILITIES(data) do 92 | {:ok, ["READER", "POST"], data} 93 | end 94 | 95 | @impl true 96 | def handle_POST(article, data) do 97 | {:ok, [article | data]} 98 | end 99 | 100 | @impl true 101 | def handle_HELP(data) do 102 | {:ok, "This NNTP server only keeps data in memory.", data} 103 | end 104 | end 105 | 106 | # Start the server 107 | {:ok, pid} = GenNNTP.start(InMemoryNNTP, port: 6791) 108 | 109 | # This is the client 110 | {:ok, socket} = GenNNTP.connect("localhost", 6791) 111 | 112 | # POST article 113 | article = %{ 114 | id: "", 115 | headers: %{ 116 | "Message-ID" => "", 117 | "From" => "\"Demo User\" ", 118 | "Newsgroups" => "misc.test", 119 | "Subject" => "I am just a test article", 120 | "Organization" => "An Example Net", 121 | }, 122 | body: "This is a test article." 123 | } 124 | {:ok, response} = GenNNTP.command(socket, "POST", [article]) 125 | #=> {:ok, "240 Article received OK"} 126 | 127 | Our NNTP server also advertises the "READER" capability, so we want to at 128 | least let the clients fetch an article from our server. We do that by adding 129 | a `c:handle_ARTICLE/2` callback to "handle" the "ARTICLE" command. 130 | 131 | This callback is particularly interesting in which it can take 2 types of 132 | arguments: either a message ID or a tuple of article number and its group. 133 | For the sake of simplicity, we only handle the message ID for our example. 134 | In actual implementation, we will also need to add `c:handle_GROUP/2` and/or 135 | `c:handle_LISTGROUP/2` to let the user select a newsgroup. This is because 136 | the second type of argument for `c:handle_ARTICLE/2` requires a newsgroup to 137 | be selected first. 138 | 139 | In our example here, we simply retrieve the article matching the ID from our 140 | internal database, or return with `false` when we can't find it. For matching 141 | article, we also return the article number. Because we don't implement the 142 | groups, we can return 0 here. 143 | 144 | defmodule InMemoryNNTP do 145 | @behaviour GenNNTP 146 | 147 | # Callbacks 148 | 149 | @impl true 150 | def init(data) do 151 | {:ok, data} 152 | end 153 | 154 | @impl true 155 | def handle_CAPABILITIES(data) do 156 | {:ok, ["READER", "POST"], data} 157 | end 158 | 159 | @impl true 160 | def handle_POST(article, data) do 161 | {:ok, [article | data]} 162 | end 163 | 164 | @impl true 165 | def handle_ARTICLE(message_id, data) when is_binary(message_id) do 166 | result = Enum.find(data, false, fn 167 | (%{id: ^message_id}) -> true 168 | (_) -> false 169 | end) 170 | 171 | case result do 172 | false -> {:ok, false, data} 173 | article -> {:ok, {0, article}, data} 174 | end 175 | end 176 | 177 | @impl true 178 | def handle_HELP(data) do 179 | {:ok, "This NNTP server only keeps data in memory.", data} 180 | end 181 | end 182 | 183 | # Start the server 184 | {:ok, pid} = GenNNTP.start(InMemoryNNTP, port: 6791) 185 | 186 | # This is the client 187 | {:ok, socket} = GenNNTP.connect("localhost", 6791) 188 | 189 | # POST article 190 | article = %{ 191 | id: "", 192 | headers: %{ 193 | "Message-ID" => "", 194 | "From" => "\"Demo User\" ", 195 | "Newsgroups" => "misc.test", 196 | "Subject" => "I am just a test article", 197 | "Organization" => "An Example Net", 198 | }, 199 | body: "This is a test article." 200 | } 201 | {:ok, response} = GenNNTP.command(socket, "POST", [article]) 202 | 203 | # ARTICLE 204 | {:ok, response} = GenNNTP.command(socket, "ARTICLE", [""]) 205 | #=> {:ok, "220 0 ""\r\nMessage-ID: \r\n...\r\n\r\nThis is a test article."} 206 | """ 207 | 208 | @type option :: :gen_nntp.option() 209 | @type article :: :gen_nntp.article() 210 | 211 | @typep state :: any 212 | 213 | # Default port from "PORT" environment variable or 199. 214 | @port String.to_integer(System.get_env("PORT", "119")) 215 | 216 | @doc """ 217 | Starts a NNTP server with a callback module. 218 | 219 | Similar to starting a `GenServer`. 220 | """ 221 | @spec start(module(), any, [option]) :: :gen_nntp.on_start() 222 | defdelegate start(module, args, options), to: :gen_nntp 223 | 224 | @doc """ 225 | Stops a NNTP server by its reference. 226 | 227 | The reference is usually the callback module. 228 | """ 229 | @spec stop(module()) :: :ok 230 | defdelegate stop(ref), to: :gen_nntp 231 | 232 | @doc """ 233 | Connects to a NNTP server and receives the greeting. 234 | 235 | ## Examples: 236 | 237 | iex> {:ok, socket, _greeting} = GenNNTP.connect() 238 | iex> is_port(socket) 239 | true 240 | 241 | iex> {:ok, socket, _greeting} = GenNNTP.connect("localhost") 242 | iex> is_port(socket) 243 | true 244 | 245 | iex> {:ok, socket, _greeting} = GenNNTP.connect( 246 | ...> "localhost", 247 | ...> String.to_integer(System.get_env("PORT", "119")) 248 | ...> ) 249 | iex> is_port(socket) 250 | true 251 | 252 | iex> {:ok, socket, _greeting} = GenNNTP.connect( 253 | ...> "localhost", 254 | ...> String.to_integer(System.get_env("PORT", "119")), 255 | ...> [] 256 | ...> ) 257 | iex> is_port(socket) 258 | true 259 | 260 | iex> {:ok, _socket, "200 " <> _} = GenNNTP.connect( 261 | ...> "localhost", 262 | ...> String.to_integer(System.get_env("PORT", "119")), 263 | ...> [] 264 | ...> ) 265 | """ 266 | defdelegate connect(address \\ "localhost", port \\ @port, options \\ []), to: :gen_nntp 267 | 268 | @doc ~S""" 269 | Sends a command and receives server's response. 270 | 271 | Both single and multi-line response are handled. The terminating 272 | line in a multi-line response is discarded, and the whole response 273 | is trimmed for whitespaces. 274 | 275 | For commands that are followed by a multi-line data block, such as 276 | "POST", place the data block as the argument to `command/3` call. 277 | 278 | The arguments will be converted to binary when possible. 279 | 280 | ## Examples 281 | 282 | iex> {:ok, socket, _greeting} = GenNNTP.connect() 283 | iex> GenNNTP.command(socket, "HELP") 284 | {:ok, "100 Help text follows\r\nThis is some help text.\r\n"} 285 | 286 | iex> {:ok, socket, _greeting} = GenNNTP.connect() 287 | iex> GenNNTP.command(socket, "CAPABILITIES") 288 | {:ok, "101 Capability list:\r\nVERSION 2\r\nREADER\r\n\POST\r\n"} 289 | 290 | iex> {:ok, socket, _greeting} = GenNNTP.connect() 291 | iex> article = %{ 292 | ...> headers: %{ 293 | ...> "Message-ID" => "", 294 | ...> "From" => "\"Demo User\" ", 295 | ...> "Newsgroups" => "misc.test", 296 | ...> "Subject" => "I am just a test article", 297 | ...> "Organization" => "An Example Net", 298 | ...> }, 299 | ...> body: "This is a test article for posting", 300 | ...> } 301 | iex> GenNNTP.command(socket, "POST", [article]) 302 | {:ok, "240 Article received OK"} 303 | """ 304 | defdelegate command(socket, command, args \\ []), to: :gen_nntp 305 | 306 | @doc """ 307 | Invoked when a client is connecting to the server. 308 | 309 | `init_arg` is the argument term (second argument) passed to `start/3`. 310 | 311 | Returning `{:ok, state}` wll start the handshake to establish the socket. 312 | 313 | Returning `{:ok, state, timeout}` is similar to `{:ok, state}`, except that 314 | it also sets a delay before establishing the handshake. 315 | 316 | Returning `:ignore` will make the process exit normally without entering the 317 | loop, closing the socket. 318 | 319 | Returning `{:stop, reason}` will cause the process to exit with reason 320 | `reason` without entering the loop, also closing the socket. 321 | """ 322 | @callback init(init_arg :: term()) :: 323 | {:ok, state} | {:ok, state, timeout} | 324 | :ignore | {:stop, reason :: term} 325 | 326 | @doc """ 327 | Invoked when a client asks for the server's capabilities. 328 | 329 | `state` is the current state of the NNTP server. 330 | 331 | The returning `capabilities` list is responded to the client, with "VERSION" 332 | always the first in the list. Server containues the loop with the new state. 333 | 334 | Only standard capabilities are responded to the client. Invalid ones in the 335 | callback's return are ignored. 336 | """ 337 | @callback handle_CAPABILITIES(state) :: {:ok, capabilities :: [String.t()], state} 338 | 339 | @doc """ 340 | Invoked when a client selects a newsgroup. 341 | 342 | `group` is the name of the newsgroup to be selected (e.g., "news.software"). 343 | 344 | Returning `{:ok, group_summary, new_state}` sends the group summary to client 345 | and continues the loop with new state `new_state`. 346 | 347 | Returning `{:ok, false, new_state}` sends 411 response to tell the client of 348 | unavailable grop and continues the loop with new state `new_state`. 349 | 350 | Returning `{:error, reason}` to respond with `reason` and closes the client. 351 | """ 352 | @callback handle_GROUP(group, state) :: 353 | {:ok, { 354 | group, 355 | number: non_neg_integer(), 356 | low: non_neg_integer(), 357 | high: non_neg_integer() 358 | }, state} | 359 | {:ok, false, state} | 360 | {:error, reason :: String.t(), state} 361 | when group: String.t() 362 | 363 | @doc """ 364 | Invoked when a client selects a newsgroup. 365 | 366 | `group` is the name of the newsgroup to be selected (e.g., "news.software"). 367 | 368 | Returning `{:ok, group_summary, new_state}` sends the group summary to client 369 | and continues the loop with new state `new_state`. The group summary is 370 | similar to the one responded by "GROUP" command, but also has a list of 371 | article numbers in the newsgroup. 372 | 373 | Returning `{:ok, false, new_state}` sends 411 response to tell the client of 374 | unavailable grop and continues the loop with new state `new_state`. 375 | 376 | Returning `{:error, reason}` to respond with `reason` and closes the client. 377 | """ 378 | @callback handle_LISTGROUP(group, state) :: 379 | {:ok, { 380 | group, 381 | number: non_neg_integer(), 382 | low: non_neg_integer(), 383 | high: non_neg_integer(), 384 | numbers: [non_neg_integer()] 385 | }, state} | 386 | {:ok, false, state} | 387 | {:error, reason :: String.t(), state} 388 | when group: String.t() 389 | 390 | @doc """ 391 | Invoked when a client selects the next article in the current newsgroup. 392 | 393 | The next article in that newsgroup is the lowest existing article number 394 | greater than the current article number. 395 | 396 | Returning `{:ok, { number, article }, new_state}` sends the new current 397 | article number and the message-id of that article to the client and continues 398 | the loop with new state `new_state`. 399 | 400 | If `number` is the same as the current article number, a 421 is responded to 401 | tell the client of no next article in this group. 402 | 403 | Returning `{:ok, false, new_state}` sends 421 response to tell the client of 404 | unavailable article and continues the loop with new state `new_state`. 405 | 406 | Returning `{:error, reason}` to respond with `reason` and closes the client. 407 | 408 | Note that this callback is not invoked when currently selected newsgroup is 409 | invalid, or the current article number is invalid. GenNNTP handles those 410 | cases with appropriate response code. 411 | """ 412 | @callback handle_NEXT(arg, state) :: 413 | {:ok, { number, :gen_nttp.article() }, state }| 414 | {:ok, false, state} | 415 | {:error, reason :: String.t(), state} 416 | when number: non_neg_integer(), 417 | arg: :gen_nttp.message_id() | {number, group :: String.t()} 418 | 419 | @doc """ 420 | Invoked when a client selects the previous article in the current newsgroup. 421 | 422 | The previous article in that newsgroup is the highest existing article number 423 | less than the current article number. 424 | 425 | Returning `{:ok, { number, article }, new_state}` sends the new current 426 | article number and the message-id of that article to the client and continues 427 | the loop with new state `new_state`. 428 | 429 | If `number` is the same as the current article number, a 422 is responded to 430 | tell the client of no next article in this group. 431 | 432 | Returning `{:ok, false, new_state}` sends 422 response to tell the client of 433 | unavailable article and continues the loop with new state `new_state`. 434 | 435 | Returning `{:error, reason}` to respond with `reason` and closes the client. 436 | 437 | Note that this callback is not invoked when currently selected newsgroup is 438 | invalid, or the current article number is invalid. GenNNTP handles those 439 | cases with appropriate response code. 440 | """ 441 | @callback handle_LAST(arg, state) :: 442 | {:ok, { number, :gen_nttp.article() }, state }| 443 | {:ok, false, state} | 444 | {:error, reason :: String.t(), state} 445 | when number: non_neg_integer(), 446 | arg: :gen_nttp.message_id() | {number, group :: String.t()} 447 | 448 | @doc """ 449 | Invoked when a client selects an article. 450 | 451 | The `arg` is the argument used to specify the article to retrieve. It has 2 452 | forms: 453 | 454 | - A message-id. 455 | - A 2-tuple containing the article number and the group name. 456 | 457 | Returning `{:ok, { number, article }, new_state}` sends the new current 458 | article number and the entire article to the client and continues 459 | the loop with new state `new_state`. A full article has message-id, the 460 | headers, and a body. 461 | 462 | Returning `{:ok, false, new_state}` sends 423 response to tell the client of 463 | unavailable article and continues the loop with new state `new_state`. 464 | 465 | Returning `{:error, reason}` to respond with `reason` and closes the client. 466 | 467 | Note that this callback is not invoked when currently selected newsgroup is 468 | invalid, or the current article number is invalid. GenNNTP handles those 469 | cases with appropriate response code. GenNNTP also handles the ARTICLE 470 | command with no argument, by taking the current article number instead before 471 | passing it to this callback. 472 | """ 473 | @callback handle_ARTICLE(arg, state) :: 474 | {:ok, { number, :gen_nttp.article() }, state }| 475 | {:ok, false, state} | 476 | {:error, reason :: String.t(), state} 477 | when number: non_neg_integer(), 478 | arg: :gen_nttp.message_id() | {number, group :: String.t()} 479 | 480 | @doc """ 481 | Invoked when a client selects headers of an article. 482 | 483 | This callback behaves identically to the `c:handle_ARTICLE/2` except that 484 | only the headers are returned in the article. 485 | """ 486 | @callback handle_HEAD(arg, state) :: 487 | {:ok, { number, :gen_nttp.article() }, state }| 488 | {:ok, false, state} | 489 | {:error, reason :: String.t(), state} 490 | when number: non_neg_integer(), 491 | arg: :gen_nttp.message_id() | {number, group :: String.t()} 492 | 493 | @doc """ 494 | Invoked when a client selects body of an article. 495 | 496 | This callback behaves identically to the `c:handle_ARTICLE/2` except that 497 | only the body is returned in the article. 498 | """ 499 | @callback handle_BODY(arg, state) :: 500 | {:ok, { number, :gen_nttp.article() }, state }| 501 | {:ok, false, state} | 502 | {:error, reason :: String.t(), state} 503 | when number: non_neg_integer(), 504 | arg: :gen_nttp.message_id() | {number, group :: String.t()} 505 | 506 | @doc """ 507 | Invoked when a client checks if an article exists. 508 | 509 | This callback behaves identically to the `c:handle_ARTICLE/2` except that 510 | only the message-id is returned in the article. 511 | 512 | This callback allows the client to determine whether an article exists and 513 | in the second forms, what its message-id is, without having to process an 514 | arbitrary amount of text. 515 | """ 516 | @callback handle_STAT(arg, state) :: 517 | {:ok, { number, :gen_nttp.article() }, state }| 518 | {:ok, false, state} | 519 | {:error, reason :: String.t(), state} 520 | when number: non_neg_integer(), 521 | arg: :gen_nttp.message_id() | {number, group :: String.t()} 522 | 523 | @doc """ 524 | Invoked when a client sends an article to be posted. 525 | 526 | The callback receives a map of type `article()` without `id` field. 527 | 528 | Returning `{:ok, new_state}` to accept the article. 529 | 530 | Returning `{:error, reason}` to reject the article with specific reason. 531 | """ 532 | @callback handle_POST(article :: :gen_nttp.article(), state) :: 533 | {:ok, state} | {:error, reason :: String.t(), state} 534 | 535 | @doc """ 536 | Invoked when a client wants summary of the server. 537 | 538 | Returning `{:ok, help_test, new_state}` to respond the `help_text` to the 539 | client and continues the loop with new state `new_state`. 540 | """ 541 | @callback handle_HELP(state) :: {:ok, help_text :: String.t(), state} 542 | 543 | @doc """ 544 | Invoked when a client quits. 545 | 546 | Returning `{:ok, new_state}` to close the connection. 547 | """ 548 | @callback handle_QUIT(state) :: {:ok, state} 549 | 550 | @doc """ 551 | Invoked when an uknown command is asked by the client. 552 | 553 | This optional callback can be used to handle commands not understood by the 554 | current GenNNTP implementation. If not defined, GenNNTP responds with 500. 555 | 556 | The `command` is full command line sent by the client, minus the LFCR pair at 557 | the end. The callback can choose to `reply` or `noreply` to the command. The 558 | `response` does not need the ending LFCR pair. 559 | 560 | Returning `{:error, reason}` to respond with `reason` and closes the client. 561 | """ 562 | @callback handle_command(command :: String.t(), state) :: 563 | {:reply, response :: any(), state} | 564 | {:noreply, state} | 565 | {:stop, reason :: any(), state} | 566 | {:stop, reason :: any(), response :: any(), state} 567 | 568 | @optional_callbacks [ 569 | handle_GROUP: 2, 570 | handle_LISTGROUP: 2, 571 | handle_NEXT: 2, 572 | handle_LAST: 2, 573 | handle_ARTICLE: 2, 574 | handle_HEAD: 2, 575 | handle_BODY: 2, 576 | handle_STAT: 2, 577 | handle_POST: 2, 578 | handle_QUIT: 1, 579 | handle_command: 2 580 | ] 581 | end 582 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule GenNntp.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.21.1" 5 | 6 | def project do 7 | [ 8 | app: :gen_nntp, 9 | version: @version, 10 | name: "GenNNTP", 11 | source_url: "https://github.com/sntran/gen_nntp", 12 | homepage_url: "https://sntran.github.io/gen_nntp", 13 | description: """ 14 | The NNTP client and server library. 15 | """, 16 | elixir: "~> 1.11", 17 | elixirc_paths: elixirc_paths(Mix.env()), 18 | start_permanent: Mix.env() == :prod, 19 | deps: deps(), 20 | package: package(), 21 | docs: docs(), 22 | ] 23 | end 24 | 25 | # Run "mix help compile.app" to learn about applications. 26 | def application do 27 | [ 28 | extra_applications: [:logger] 29 | ] 30 | end 31 | 32 | # Specifies which paths to compile per environment. 33 | defp elixirc_paths(:test), do: ["lib", "test/support"] 34 | defp elixirc_paths(_), do: ["lib"] 35 | 36 | # Run "mix help deps" to learn about dependencies. 37 | defp deps do 38 | [ 39 | {:ranch, "~> 1.7.1"}, 40 | {:mix_test_watch, "~> 1.0", only: :dev, runtime: false}, 41 | {:ex_doc, "~> 0.23", only: :dev, runtime: false}, 42 | ] 43 | end 44 | 45 | defp package do 46 | [ 47 | maintainers: [ 48 | "Son Tran-Nguyen" 49 | ], 50 | licenses: ["Apache 2.0"], 51 | links: %{github: "https://github.com/sntran/gen_nntp"}, 52 | files: 53 | ~w(examples include lib priv src) ++ 54 | ~w(.formatter.exs mix.exs CHANGELOG.md LICENSE README.md), 55 | exclude_patterns: [".DS_Store"] 56 | ] 57 | end 58 | 59 | defp docs do 60 | [ 61 | main: "GenNNTP", 62 | source_ref: "v#{@version}" 63 | ] 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark_parser": {:hex, :earmark_parser, "1.4.12", "b245e875ec0a311a342320da0551da407d9d2b65d98f7a9597ae078615af3449", [:mix], [], "hexpm", "711e2cc4d64abb7d566d43f54b78f7dc129308a63bc103fbd88550d2174b3160"}, 3 | "ex_doc": {:hex, :ex_doc, "0.23.0", "a069bc9b0bf8efe323ecde8c0d62afc13d308b1fa3d228b65bca5cf8703a529d", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f5e2c4702468b2fd11b10d39416ddadd2fcdd173ba2a0285ebd92c39827a5a16"}, 4 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 5 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 6 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, 7 | "mix_test_watch": {:hex, :mix_test_watch, "1.0.2", "34900184cbbbc6b6ed616ed3a8ea9b791f9fd2088419352a6d3200525637f785", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "47ac558d8b06f684773972c6d04fcc15590abdb97aeb7666da19fcbfdc441a07"}, 8 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 9 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, 10 | } 11 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # Erlang sources 2 | 3 | The `src` directory is used to store Erlang source files. The private `.hrl` files are usually kept inside the `src/` directory as well. 4 | -------------------------------------------------------------------------------- /src/gen_nntp.erl: -------------------------------------------------------------------------------- 1 | -module(gen_nntp). 2 | -author('dev@sntran.com'). 3 | -behaviour(gen_server). 4 | 5 | %% API 6 | -export([ 7 | start/3, 8 | stop/1, 9 | connect/0, 10 | connect/1, 11 | connect/2, 12 | connect/3, 13 | command/2, 14 | command/3 15 | ]). 16 | 17 | %% gen_server callbacks 18 | -export([ 19 | init/1, 20 | handle_call/3, 21 | handle_cast/2, 22 | handle_info/2, 23 | terminate/2, 24 | code_change/3 25 | ]). 26 | 27 | %% ranch_protocol callbacks 28 | -export([ 29 | start_link/3, 30 | start_link/4 31 | ]). 32 | 33 | -export_type([ 34 | name/0, 35 | option/0, 36 | on_start/0, 37 | message_id/0, 38 | article/0 39 | ]). 40 | 41 | -include("gen_nntp.hrl"). 42 | -include("gen_nntp_internal.hrl"). 43 | 44 | -record(client, { 45 | transport :: module(), 46 | socket :: socket(), 47 | module :: module(), 48 | % currently selected newsgroup 49 | group :: invalid | binary(), 50 | % current article number 51 | article_number :: invalid | non_neg_integer(), 52 | state :: state() 53 | }). 54 | 55 | -define(PORT, list_to_integer(os:getenv("PORT", "119"))). 56 | -define(NNTP_VERSION, <<"2">>). 57 | -define(CAPABILITIES, [ 58 | <<"HDR">>, 59 | <<"IHAVE">>, 60 | <<"LIST">>, 61 | <<"MODE-READER">>, 62 | <<"NEWNEWS">>, 63 | <<"OVER">>, 64 | <<"POST">>, 65 | <<"READER">> 66 | ]). 67 | 68 | -callback init(Args :: term()) -> 69 | {ok, state()} 70 | | ignore 71 | | {stop, Reason :: any()}. 72 | 73 | -callback handle_CAPABILITIES(state()) -> {ok, Capabilities :: [binary()], state()}. 74 | 75 | -callback handle_GROUP(Group, state()) -> 76 | {ok, { 77 | Group, Number :: non_neg_integer(), 78 | Low :: non_neg_integer(), 79 | High :: non_neg_integer() 80 | }, state()} 81 | | {ok, false, state()} 82 | | {error, Reason :: binary(), state()} 83 | when Group :: binary(). 84 | 85 | -callback handle_LISTGROUP(Group, state()) -> 86 | {ok, { 87 | Group, Number :: non_neg_integer(), 88 | Low :: non_neg_integer(), 89 | High :: non_neg_integer() 90 | }, state()} 91 | | {ok, false, state()} 92 | | {error, Reason :: binary(), state()} 93 | when Group :: binary(). 94 | 95 | -callback handle_NEXT(Arg, state()) -> 96 | {ok, { Number, article() }, state()} 97 | | {ok, false, state()} 98 | | {error, Reason :: binary(), state()} 99 | when Number :: non_neg_integer(), 100 | Arg :: message_id() | {Number, Group :: binary()}. 101 | 102 | -callback handle_LAST(Arg, state()) -> 103 | {ok, { Number, article() }, state()} 104 | | {ok, false, state()} 105 | | {error, Reason :: binary(), state()} 106 | when Number :: non_neg_integer(), 107 | Arg :: message_id() | {Number, Group :: binary()}. 108 | 109 | -callback handle_ARTICLE(Arg, state()) -> 110 | {ok, { Number, article() }, state()} 111 | | {ok, false, state()} 112 | | {error, Reason :: binary(), state()} 113 | when Number :: non_neg_integer(), 114 | Arg :: message_id() | {Number, Group :: binary()}. 115 | 116 | -callback handle_HEAD(Arg, state()) -> 117 | {ok, { Number, article() }, state()} 118 | | {ok, false, state()} 119 | | {error, Reason :: binary(), state()} 120 | when Number :: non_neg_integer(), 121 | Arg :: message_id() | {Number, Group :: binary()}. 122 | 123 | -callback handle_BODY(Arg, state()) -> 124 | {ok, { Number, article() }, state()} 125 | | {ok, false, state()} 126 | | {error, Reason :: binary(), state()} 127 | when Number :: non_neg_integer(), 128 | Arg :: message_id() | {Number, Group :: binary()}. 129 | 130 | -callback handle_STAT(Arg, state()) -> 131 | {ok, { Number, article() }, state()} 132 | | {ok, false, state()} 133 | | {error, Reason :: binary(), state()} 134 | when Number :: non_neg_integer(), 135 | Arg :: message_id() | {Number, Group :: binary()}. 136 | 137 | -callback handle_POST(article(), state()) -> 138 | {ok, state()} | {error, Reason :: binary(), state()}. 139 | 140 | -callback handle_HELP(state()) -> {ok, HelpText :: binary(), state()}. 141 | 142 | -callback handle_QUIT(state()) -> {ok, state()}. 143 | 144 | -callback handle_command(Command :: binary(), state()) -> 145 | {reply, Response :: binary(), state()} 146 | | {noreply, state()} 147 | | {stop, Reason :: any(), state()} 148 | | {stop, Reason :: any, Response :: binary(), state()}. 149 | 150 | -optional_callbacks([ 151 | handle_GROUP/2, 152 | handle_LISTGROUP/2, 153 | handle_NEXT/2, 154 | handle_LAST/2, 155 | handle_ARTICLE/2, 156 | handle_HEAD/2, 157 | handle_BODY/2, 158 | handle_STAT/2, 159 | handle_POST/2, 160 | handle_QUIT/1, 161 | handle_command/2 162 | ]). 163 | 164 | %% ================================================================== 165 | %% API 166 | %% ================================================================== 167 | 168 | %%------------------------------------------------------------------- 169 | %% @doc Starts a NNTP server with a callback module. 170 | %% 171 | %% Similar to starting a `gen_server`. 172 | %% @end 173 | %%------------------------------------------------------------------- 174 | -spec start(module(), term(), [option()]) -> on_start(). 175 | start(Module, Args, Options) when is_atom(Module) -> 176 | ok = application:ensure_started(ranch), 177 | 178 | Port = proplists:get_value(port, Options, ?PORT), 179 | Options1 = proplists:delete(port, Options), 180 | 181 | ProtocolOpts = { Module, Args, Options1 }, 182 | case ranch:start_listener( 183 | Module, 184 | ranch_tcp, [{port, Port}], 185 | ?MODULE, ProtocolOpts 186 | ) of 187 | {ok, Listener} -> {ok, Listener}; 188 | {error, {already_started, Listener}} -> {ok, Listener} 189 | end. 190 | 191 | %%------------------------------------------------------------------- 192 | %% @doc Stops a NNTP server by its reference. 193 | %% 194 | %% The reference is usually the callback module. 195 | %% @end 196 | %%------------------------------------------------------------------- 197 | -spec stop(module()) -> ok. 198 | stop(Ref) -> 199 | try ranch:stop_listener(Ref) of 200 | _ -> ok 201 | catch 202 | _:_ -> ok 203 | end. 204 | 205 | %%------------------------------------------------------------------- 206 | %% @doc Connects to a NNTP server. 207 | %% 208 | %% @end 209 | %%------------------------------------------------------------------- 210 | -spec connect() -> {ok, socket(), Greeting :: binary()} | {error, connect_error()}. 211 | connect() -> 212 | connect("localhost", ?PORT, []). 213 | 214 | -spec connect(address()) -> {ok, socket(), Greeting :: binary()} | {error, connect_error()}. 215 | connect(Address) -> 216 | connect(Address, ?PORT, []). 217 | 218 | -spec connect(address(), port_number()) -> {ok, socket(), Greeting :: binary()} | {error, connect_error()}. 219 | connect(Address, Port) -> 220 | connect(Address, Port, []). 221 | 222 | -spec connect(address(), port_number(), [gen_tcp:connect_option()]) -> {ok, socket(), Greeting :: binary()} | {error, connect_error()}. 223 | connect(Address, Port, Options) when is_binary(Address) -> 224 | connect(binary_to_list(Address), Port, Options); 225 | 226 | connect(Address, Port, _Options) -> 227 | % @TODO: Merge default options with user-supplied options. 228 | Options = [binary, {packet, line}, {active, false}], 229 | case gen_tcp:connect(Address, Port, Options) of 230 | {ok, Socket} -> 231 | {ok, Greeting} = gen_tcp:recv(Socket, 0, 1000), 232 | {ok, Socket, string:chomp(Greeting)}; 233 | {error, Reason} -> 234 | {error, Reason} 235 | end. 236 | 237 | %%------------------------------------------------------------------- 238 | %% @doc Sends a command to a NNTP socket 239 | %% 240 | %% The function will also wait for the response from server, both 241 | %% single and multi-line responses. 242 | %% 243 | %% For commands that are followed by a multi-line data block, such as 244 | %% "POST", place the block as the argument to `command/3` call. 245 | %% @end 246 | %%------------------------------------------------------------------- 247 | -spec command(socket(), binary()) -> {ok, binary()} | {error, recv_error()}. 248 | command(Socket, Commamd) -> 249 | command(Socket, Commamd, []). 250 | 251 | -spec command(socket(), binary(), Args :: list()) -> {ok, binary()} | {error, recv_error()}. 252 | command(Socket, <<"POST">> = Command, [Article]) -> 253 | EmptyList = [], 254 | {ok, Response} = command(Socket, Command, EmptyList), 255 | case Response of 256 | <<"340 ", _/binary>> -> 257 | MultiLine = join(<<"\r\n">>, [to_binary(Article), <<".">>]), 258 | command(Socket, MultiLine, EmptyList); 259 | <<"440 ">> -> 260 | {ok, Response} 261 | end; 262 | 263 | command(Socket, Command, Args) when is_binary(Command), is_list(Args) -> 264 | Line = join(<<" ">>, [Command | to_binary(Args)]), 265 | ok = gen_tcp:send(Socket, [Line, <<"\r\n">>]), 266 | recv(Socket, Command). 267 | 268 | recv(Socket, <<"CAPABILITIES">>) -> 269 | multiline(Socket, gen_tcp:recv(Socket, 0, 1000)); 270 | recv(Socket, <<"LIST">>) -> 271 | multiline(Socket, gen_tcp:recv(Socket, 0, 1000)); 272 | recv(Socket, <<"LISTGROUP">>) -> 273 | multiline(Socket, gen_tcp:recv(Socket, 0, 1000)); 274 | recv(Socket, <<"ARTICLE">>) -> 275 | multiline(Socket, gen_tcp:recv(Socket, 0, 1000)); 276 | recv(Socket, <<"HEAD">>) -> 277 | multiline(Socket, gen_tcp:recv(Socket, 0, 1000)); 278 | recv(Socket, <<"BODY">>) -> 279 | multiline(Socket, gen_tcp:recv(Socket, 0, 1000)); 280 | recv(Socket, <<"OVER">>) -> 281 | multiline(Socket, gen_tcp:recv(Socket, 0, 1000)); 282 | recv(Socket, <<"XOVER">>) -> 283 | multiline(Socket, gen_tcp:recv(Socket, 0, 1000)); 284 | recv(Socket, <<"HDR">>) -> 285 | multiline(Socket, gen_tcp:recv(Socket, 0, 1000)); 286 | recv(Socket, <<"XHDR">>) -> 287 | multiline(Socket, gen_tcp:recv(Socket, 0, 1000)); 288 | recv(Socket, <<"NEWNEWS">>) -> 289 | multiline(Socket, gen_tcp:recv(Socket, 0, 1000)); 290 | recv(Socket, <<"NEWGROUPS">>) -> 291 | multiline(Socket, gen_tcp:recv(Socket, 0, 1000)); 292 | recv(Socket, <<"HELP">>) -> 293 | multiline(Socket, gen_tcp:recv(Socket, 0, 1000)); 294 | recv(Socket, _Command) -> 295 | {ok, Line} = gen_tcp:recv(Socket, 0, 1000), 296 | {ok, string:chomp(Line)}. 297 | 298 | %% ================================================================== 299 | %% ranch_protocol Callbacks 300 | %% ================================================================== 301 | 302 | % ranch 1 303 | start_link(Module, _Socket, Transport, { Module, _, _ } = Options) when is_atom(Transport) -> 304 | start_link(Module, Transport, Options). 305 | 306 | % ranch_protocol 2 307 | start_link(Module, Transport, { Module, _, _ } = Options) when is_atom(Transport) -> 308 | gen_server:start_link(?MODULE, { Transport, Options }, []). 309 | 310 | %% ================================================================== 311 | %% gen_server Callbacks 312 | %% ================================================================== 313 | 314 | %% @private 315 | init({ Transport, { Module, Args, _Options } }) -> 316 | % traps exit signals so we can clean up when terminated by supervisor. 317 | process_flag(trap_exit, true), 318 | 319 | Client = #client{ 320 | transport = Transport, 321 | module = Module, 322 | % At the start of an NNTP session, selected newsgroup is set to invalid. 323 | group = invalid, 324 | % At the start of an NNTP session, current article number is invalid. 325 | article_number = invalid 326 | }, 327 | 328 | case Module:init(Args) of 329 | {ok, State} -> 330 | % Set timeout to 0 so we can handle handshake. 331 | {ok, Client#client{state = State}, 0}; 332 | {ok, State, Delay} when is_integer(Delay) -> 333 | {ok, Client#client{state = State}, Delay}; 334 | ignore -> 335 | ignore; 336 | {stop, Reason} -> 337 | {stop, Reason}; 338 | Else -> 339 | Else 340 | end. 341 | 342 | %% @private 343 | handle_call(_, _From, Client) -> {reply, ok, Client}. 344 | 345 | %% @private 346 | handle_cast(stop, Client) -> {stop, normal, Client}. 347 | 348 | % Received after initialization timeout. Starts the handshake. 349 | handle_info(timeout, #client{module =Module, transport = Transport} = Client) -> 350 | {ok, Socket} = ranch:handshake(Module), 351 | ok = Transport:setopts(Socket, [{active, once}, {packet, line}]), 352 | 353 | Transport:send(Socket, "200 Service available, posting allowed\r\n"), 354 | {noreply, Client}; 355 | 356 | % Command message from the client. 357 | handle_info({tcp, Socket, Line}, #client{transport = Transport} = Client) -> 358 | % Removes the CRLF pair. 359 | Command = string:chomp(Line), 360 | 361 | case handle_command(Command, Client#client{socket = Socket}) of 362 | {reply, Reply, NewClient} -> 363 | Transport:send(Socket, [Reply, <<"\r\n">>]), 364 | ok = Transport:setopts(Socket, [{active, once}]), 365 | {noreply, NewClient}; 366 | 367 | {noreply, NewClient} -> 368 | {noreply, NewClient}; 369 | 370 | {stop, Reason, Reply, NewClient} -> 371 | Transport:send(Socket, [Reply, <<"\r\n">>]), 372 | Transport:close(Socket), 373 | {stop, Reason, NewClient} 374 | end; 375 | 376 | handle_info({tcp_closed, _Socket}, Client) -> 377 | {stop, normal, Client}; 378 | 379 | handle_info({tcp_error, _Socket, Reason}, Client) -> 380 | {stop, Reason, Client}; 381 | 382 | % handles exit message, if the gen_server is linked to other processes (than 383 | % the supervisor) and trapping exit signals. 384 | handle_info({'EXIT', _Pid, _Reason}, Client) -> 385 | % ..code to handle exits here.. 386 | {noreply, Client}; 387 | 388 | handle_info(_Info, Client) -> 389 | {noreply, Client}. 390 | 391 | %% @private 392 | terminate(normal, _Client) -> 393 | % handles normal termination when callback retruns `{stop, normal, Client}` 394 | ok; 395 | 396 | terminate(shutdown, _Client) -> 397 | % ..code for cleaning up here.. 398 | ok; 399 | 400 | terminate(_, _Client) -> ok. 401 | 402 | code_change(_OldVsn, Client, _Extra) -> 403 | % ..code to convert state (and more) during code change 404 | {ok, Client}. 405 | 406 | %% ================================================================== 407 | %% Command Handlers 408 | %% ================================================================== 409 | 410 | % Client asks for server's capabilities. Responds with 101 code. 411 | % Follows with the capabilities returned from `handle_CAPABILITIES/1` callback. 412 | handle_command(<<"CAPABILITIES">>, Client) -> 413 | #client{module = Module, state = State} = Client, 414 | % Asks the callback module to provide the capacitities at this moment. 415 | {ok, Capabilities, State1} = Module:handle_CAPABILITIES(State), 416 | 417 | % Retrieve the VERSION capability from returned list if any. 418 | Version = case lists:search(fun is_version/1, Capabilities) of 419 | false -> <<"VERSION ", ?NNTP_VERSION/binary>>; 420 | {value, Value} -> Value 421 | end, 422 | 423 | % Build multi-line data block responsefollowing the 101 response code. 424 | Reply = [ 425 | join(<<"\r\n">>, [ 426 | <<"101 Capability list:">>, % Command response with code 427 | Version % Then the version 428 | | [ 429 | % And all the standard capabilities. 430 | X || X <- Capabilities, is_capability(X) 431 | ] 432 | ]), 433 | % Ends the multi-line data block with a termination line. 434 | <<"\r\n.">> 435 | ], 436 | 437 | {reply, Reply, Client#client{state = State1}}; 438 | 439 | % Client selects a newsgroup as the currently selected newsgroup and returns 440 | % summary information about it with 211 code, or 411 if not available. 441 | % When a valid group is selected by means of this command, the currently 442 | % selected newsgroup MUST be set to that group. 443 | handle_command(<<"GROUP ", Group/binary>> = Cmd, Client) -> 444 | #client{ 445 | module = Module, 446 | article_number = CurrentArticleNumber, 447 | group = SelectedGroup, 448 | state = State 449 | } = Client, 450 | 451 | % Asks the callback module to provide the capacitities at this moment. 452 | {ok, Capabilities, State1} = Module:handle_CAPABILITIES(State), 453 | 454 | {Reply, NewArticleNumber, NewGroup, NewState} = case is_capable(Cmd, Capabilities) of 455 | true -> 456 | {ok, GroupInfo, State2} = Module:handle_GROUP(Group, State1), 457 | 458 | {ArticleNumber, Response} = case GroupInfo of 459 | % An empty newsgroup is selected, 460 | {Group, 0, 0, 0} -> 461 | % The current article number is made invalid 462 | {invalid, <<"211 0 0 0 ", Group/binary, " Group successfully selected">>}; 463 | % A valid group is selected 464 | {Group, Number, Low, High} -> 465 | {Low, join(<<" ">>, [ 466 | <<"211">>, 467 | integer_to_binary(Number), 468 | integer_to_binary(Low), 469 | integer_to_binary(High), 470 | Group, 471 | <<"Group successfully selected">> 472 | ])}; 473 | % No group 474 | false -> 475 | {CurrentArticleNumber, <<"411 No such newsgroup">>} 476 | end, 477 | 478 | {Response, ArticleNumber, Group, State2}; 479 | false -> 480 | {<<"411 No such newsgroup">>, CurrentArticleNumber, SelectedGroup, State1} 481 | end, 482 | 483 | {reply, Reply, Client#client{ 484 | article_number = NewArticleNumber, 485 | group = NewGroup, 486 | state = NewState 487 | }}; 488 | 489 | % Client selects a newsgroup as the currently selected newsgroup and returns 490 | % summary information about it with 211 code, or 411 if not available. 491 | % It also provides a list of article numbers in the newsgroup. 492 | % If no group is specified and the currently selected newsgroup is invalid, 493 | % a 412 response MUST be returned. 494 | % @TODO: Handle `range` argument. 495 | handle_command(<<"LISTGROUP">>, #client{group = invalid} = Client) -> 496 | {reply, <<"412 No newsgroup selected">>, Client}; 497 | 498 | handle_command(<<"LISTGROUP">>, #client{group = Group} = Client) -> 499 | handle_command(<<"LISTGROUP ", Group/binary>>, Client); 500 | 501 | handle_command(<<"LISTGROUP ", Group/binary>> = Cmd, Client) -> 502 | #client{module = Module, state = State} = Client, 503 | % Asks the callback module to provide the capacitities at this moment. 504 | {ok, Capabilities, State1} = Module:handle_CAPABILITIES(State), 505 | 506 | {Reply, NewState} = case is_capable(Cmd, Capabilities) of 507 | true -> 508 | {ok, GroupInfo, State2} = Module:handle_LISTGROUP(Group, State1), 509 | 510 | Response = case GroupInfo of 511 | % Group exists 512 | {Group, Number, Low, High, ArticleNumbers} -> 513 | GroupLine = join(<<" ">>, [ 514 | <<"211">>, 515 | integer_to_binary(Number), 516 | integer_to_binary(Low), 517 | integer_to_binary(High), 518 | Group, 519 | <<"list follows">> 520 | ]), 521 | 522 | join(<<"\r\n">>, [ 523 | GroupLine, 524 | lists:foldr(fun(ArticleNumber, AccIn) -> 525 | ArticleNumberBinary = integer_to_binary(ArticleNumber), 526 | <> 527 | end, <<".">>, ArticleNumbers) 528 | ]); 529 | % No group 530 | false -> 531 | <<"411 No such newsgroup">> 532 | end, 533 | 534 | {Response, State2}; 535 | false -> 536 | {<<"411 No such newsgroup">>, State1} 537 | end, 538 | 539 | {reply, Reply, Client#client{state = NewState}}; 540 | 541 | % The currently selected group is invalid, and no argument is specified. 542 | handle_command(<<"NEXT">>, #client{group = invalid} = Client) -> 543 | {reply, <<"412 No newsgroup selected">>, Client}; 544 | 545 | % Have currently selected group, but current article number is invalid. 546 | handle_command(<<"NEXT">>, #client{article_number = invalid} = Client) -> 547 | {reply, <<"420 Current article number is invalid">>, Client}; 548 | 549 | handle_command(<<"NEXT">> = Cmd, Client) -> 550 | #client{ 551 | module = Module, 552 | state = State, 553 | group = Group, 554 | article_number = ArticleNumber 555 | } = Client, 556 | 557 | % Asks the callback module to provide the capacitities at this moment. 558 | {ok, Capabilities, State1} = Module:handle_CAPABILITIES(State), 559 | 560 | {Reply, NewNumber, NewState} = case is_capable(Cmd, Capabilities) of 561 | true -> 562 | {ok, ArticleInfo, State2} = Module:handle_NEXT({ArticleNumber, Group}, State1), 563 | case ArticleInfo of 564 | false -> {<<"421 No article with that number">>, ArticleNumber, State2}; 565 | 566 | % Current article number is already the last article 567 | {ArticleNumber, _} -> 568 | Response = <<"421 No next article in this group">>, 569 | {Response, ArticleNumber, State2}; 570 | 571 | % Next article exists 572 | {Number, #{id := Id}} -> 573 | Response = [<<"223 ">>, integer_to_binary(Number), <<" ">>, Id, <<" Article found">>], 574 | {Response, Number, State2} 575 | end; 576 | false -> 577 | {<<"412 No newsgroup selected">>, State1} 578 | end, 579 | {reply, Reply, Client#client{article_number = NewNumber, state = NewState}}; 580 | 581 | % The currently selected group is invalid, and no argument is specified. 582 | handle_command(<<"LAST">>, #client{group = invalid} = Client) -> 583 | {reply, <<"412 No newsgroup selected">>, Client}; 584 | 585 | % Have currently selected group, but current article number is invalid. 586 | handle_command(<<"LAST">>, #client{article_number = invalid} = Client) -> 587 | {reply, <<"420 Current article number is invalid">>, Client}; 588 | 589 | handle_command(<<"LAST">> = Cmd, Client) -> 590 | #client{ 591 | module = Module, 592 | state = State, 593 | group = Group, 594 | article_number = ArticleNumber 595 | } = Client, 596 | 597 | % Asks the callback module to provide the capacitities at this moment. 598 | {ok, Capabilities, State1} = Module:handle_CAPABILITIES(State), 599 | 600 | {Reply, NewState} = case is_capable(Cmd, Capabilities) of 601 | true -> 602 | {ok, ArticleInfo, State2} = Module:handle_LAST({ArticleNumber, Group}, State1), 603 | case ArticleInfo of 604 | false -> {<<"422 No article with that number">>, State2}; 605 | 606 | % Current article number is already the first article 607 | {ArticleNumber, _} -> 608 | Response = <<"422 No previous article in this group">>, 609 | {Response, State2}; 610 | 611 | % Previous article exists 612 | {Number, #{id := Id}} -> 613 | Response = [<<"223 ">>, integer_to_binary(Number), <<" ">>, Id, <<" Article found">>], 614 | {Response, State2} 615 | end; 616 | false -> 617 | {<<"412 No newsgroup selected">>, State1} 618 | end, 619 | {reply, Reply, Client#client{state = NewState}}; 620 | 621 | % Both ARTICLE, HEAD, BODY, and STAT have similar response. 622 | handle_command(<<"ARTICLE", Arg/binary>>, Client) -> 623 | handle_article(<<"ARTICLE">>, Arg, Client); 624 | 625 | handle_command(<<"HEAD", Arg/binary>>, Client) -> 626 | handle_article(<<"HEAD">>, Arg, Client); 627 | 628 | handle_command(<<"BODY", Arg/binary>>, Client) -> 629 | handle_article(<<"BODY">>, Arg, Client); 630 | 631 | handle_command(<<"STAT", Arg/binary>>, Client) -> 632 | handle_article(<<"STAT">>, Arg, Client); 633 | 634 | % Client wants to post an article. Responds with 340 to tell the client to send 635 | % the article as multi-line block data; otherwise, responds with 440 if POSTING 636 | % is not allowed. 637 | % The multi-line block data is parsed into a map of type `article()` and sent 638 | % to the `handle_POST/2` callback for processing. 639 | % The callback can decide wheether to accept the article with an ok-tuple, or 640 | % reject it with an error-tuple. 641 | % The callback only needs to accept the article, and do the actual transfer in 642 | % async fashion if needs to. 643 | handle_command(<<"POST">>, Client) -> 644 | #client{ 645 | transport = Transport, socket = Socket, 646 | module = Module, state = State 647 | } = Client, 648 | 649 | % Asks the callback module to provide the capacitities at this moment. 650 | {ok, Capabilities, State1} = Module:handle_CAPABILITIES(State), 651 | 652 | case is_capable(<<"POST">>, Capabilities) of 653 | true -> 654 | Transport:send(Socket, [<<"340 Input article; end with .">>, <<"\r\n">>]), 655 | 656 | case gen_tcp:recv(Socket, 0, 1000) of 657 | % The socket can be closed at any point. 658 | {error, closed} -> 659 | {noreply, Client}; 660 | Result -> 661 | {ok, Lines} = multiline(Socket, Result), 662 | Article = to_article(Lines), 663 | 664 | {Reply, NewState} = case Module:handle_POST(Article, State1) of 665 | {ok, State2} -> 666 | {<<"240 Article received OK">>, State2}; 667 | {error, Reason, State2} -> 668 | {<<"441 ", Reason/binary>>, State2} 669 | end, 670 | 671 | {reply, Reply, Client#client{state = NewState}} 672 | end; 673 | false -> 674 | {reply, <<"440 Posting not permitted">>, Client#client{state = State1}} 675 | end; 676 | 677 | % Clients wants to find out the current Coordinated Universal Time from the 678 | % server's perspective. Responds with 111 and the date and time on the server 679 | % in the form yyyymmddhhmmss. 680 | handle_command(<<"DATE">>, Client) -> 681 | Now = erlang:timestamp(), 682 | {{Years, Months, Days},{Hours, Minutes, Seconds}} = calendar:now_to_universal_time(Now), 683 | 684 | Reply = [ 685 | <<"111 ">>, 686 | integer_to_binary(Years), 687 | string:pad(integer_to_binary(Months), 2, leading, <<"0">>), 688 | string:pad(integer_to_binary(Days), 2, leading, <<"0">>), 689 | string:pad(integer_to_binary(Hours), 2, leading, <<"0">>), 690 | string:pad(integer_to_binary(Minutes), 2, leading, <<"0">>), 691 | string:pad(integer_to_binary(Seconds), 2, leading, <<"0">>) 692 | ], 693 | 694 | {reply, Reply, Client}; 695 | 696 | % This command provides a short summary of the commands that are 697 | % understood by this implementation of the server. The help text will 698 | % be presented as a multi-line data block following the 100 response 699 | % code. This text is not guaranteed to be in any particular format (but must 700 | % be UTF-8) and MUST NOT be used by clients as a replacement for the 701 | % CAPABILITIES command 702 | handle_command(<<"HELP">>, Client) -> 703 | #client{module = Module, state = State} = Client, 704 | {ok, Help, NewState} = Module:handle_HELP(State), 705 | 706 | Reply = [ 707 | <<"100 Help text follows\r\n">>, 708 | Help, <<"\r\n">>, 709 | <<".">> 710 | ], 711 | {reply, Reply, Client#client{state = NewState}}; 712 | 713 | % The client uses the QUIT command to terminate the session. The server 714 | % MUST acknowledge the QUIT command and then close the connection to 715 | % the client. 716 | handle_command(<<"QUIT">>, Client) -> 717 | #client{module = Module, state = State} = Client, 718 | Client1 = case erlang:function_exported(Module, handle_QUIT, 1) of 719 | false -> 720 | Client; 721 | true -> 722 | {ok, NewState} = Module:handle_QUIT(State), 723 | Client#client{state = NewState} 724 | end, 725 | 726 | {stop, normal, <<"205 Connection closing">>, Client1}; 727 | 728 | % Any other commands. 729 | handle_command(Command, Client) -> 730 | #client{module = Module, state = State} = Client, 731 | 732 | case erlang:function_exported(Module, handle_command, 2) of 733 | false -> 734 | {reply, <<"500 Unknown command">>, State}; 735 | true -> 736 | case Module:handle_command(Command, State) of 737 | {reply, Reply, State1} -> 738 | {reply, Reply, Client#client{state = State1}}; 739 | {noreply, State1} -> 740 | {noreply, Client#client{state = State1}}; 741 | {stop, Reason, State1} -> 742 | {stop, Reason, Client#client{state = State1}}; 743 | {stop, Reason, Reply, State1} -> 744 | {stop, Reason, Reply, Client#client{state = State1}} 745 | end 746 | end. 747 | 748 | % Trim the leading whitespace if any. 749 | handle_article(Type, <<" ", Arg/binary>>, Client) -> 750 | handle_article(Type, Arg, Client); 751 | 752 | % The currently selected group is invalid, and no argument is specified. 753 | handle_article(_Type, <<"">>, #client{group = invalid} = Client) -> 754 | {reply, <<"412 No newsgroup selected">>, Client}; 755 | 756 | % Have currently selected group, but current article number is invalid. 757 | handle_article(_Type, <<"">>, #client{article_number = invalid} = Client) -> 758 | {reply, <<"420 Current article number is invalid">>, Client}; 759 | 760 | handle_article(Type, <<"">>, #client{article_number = ArticleNumber} = Client) -> 761 | % @FIXME: Double conversion. 762 | handle_article(Type, to_binary(ArticleNumber), Client); 763 | 764 | % Client requests for an article by message ID or article number. 765 | handle_article(Type, Arg, Client) -> 766 | #client{module = Module, group = CurrentGroup, state = State} = Client, 767 | % Asks the callback module to provide the capacitities at this moment. 768 | {ok, Capabilities, State1} = Module:handle_CAPABILITIES(State), 769 | 770 | {Reply, NewState} = case is_capable(Type, Capabilities) of 771 | true -> 772 | SuccessCode = case Type of 773 | <<"ARTICLE">> -> <<"220">>; 774 | <<"HEAD">> -> <<"221">>; 775 | <<"BODY">> -> <<"222">>; 776 | <<"STAT">> -> <<"223">> 777 | end, 778 | 779 | Callback = binary_to_existing_atom(<<"handle_", Type/binary>>), 780 | 781 | % Checks if the argument is a number or a message ID. 782 | try {CurrentGroup, binary_to_integer(Arg)} of 783 | % Argument is a number, but current group is invalid, responds with 784 | {invalid, _ArticleNumber} -> 785 | {<<"412 No newsgroup selected">>, State1}; 786 | % Argument is a number, and current group is valid, ask the callback for article. 787 | {_, ArticleNumber} -> 788 | {ok, ArticleInfo, State2} = apply(Module, Callback, [{ArticleNumber, CurrentGroup}, State1]), 789 | case ArticleInfo of 790 | false -> {<<"423 No article with that number">>, State2}; 791 | 792 | % Article specified by article number exists 793 | {Number, #{id := Id} = Article} -> 794 | Response = join(<<"\r\n">>, [ 795 | join(<<" ">>, [SuccessCode, to_binary(Number), Id]), 796 | to_binary(Article), 797 | <<".">> 798 | ]), 799 | {Response, State2} 800 | end 801 | catch 802 | error:badarg -> 803 | {ok, ArticleInfo, State2} = apply(Module, Callback, [Arg, State1]), 804 | case ArticleInfo of 805 | false -> {<<"430 No article with that message-id">>, State2}; 806 | 807 | % Article specified by message ID exists 808 | {Number, #{id := Id} = Article} -> 809 | Line = join(<<" ">>, [SuccessCode, to_binary(Number), Id]), 810 | 811 | Response = case to_binary(Article) of 812 | <<"">> -> Line; 813 | MultiLine -> [ 814 | Line, <<"\r\n">>, 815 | MultiLine, <<"\r\n">>, 816 | <<".">> 817 | ] 818 | end, 819 | 820 | {Response, State2} 821 | end 822 | end; 823 | false -> 824 | {<<"430 No article with that message-id">>, State1} 825 | end, 826 | 827 | {reply, Reply, Client#client{state = NewState}}. 828 | 829 | %% ================================================================== 830 | %% Internal Funtions 831 | %% ================================================================== 832 | 833 | % Collects a multi-line response from a socket. 834 | % Stops when encountered a ".". Last CRLF is removed. 835 | %% @private 836 | -spec multiline(socket(), {ok, binary()} | {error, recv_error()}) -> 837 | {ok, binary()} | {error, Reason :: binary()}. 838 | multiline(_Socket, {error, Reason}) -> 839 | {error, Reason}; 840 | 841 | % 4xx response is an error in a single line. 842 | multiline(_Socket, {ok, <<"4", _/binary>> = Acc}) -> 843 | {ok, string:chomp(Acc)}; 844 | 845 | % We only need to handle multi-line response with 2xx code. 846 | multiline(Socket, {ok, Acc}) -> 847 | case gen_tcp:recv(Socket, 0, 1000) of 848 | % End of the multi-line response. 849 | {ok, <<".\r\n">>} -> 850 | {ok, Acc}; 851 | {ok, Line} when is_binary(Line) -> 852 | multiline(Socket, {ok, <>}); 853 | {error, Reason} -> 854 | {error, Reason} 855 | end. 856 | 857 | % Checks if a text match "VERSION" capability. 858 | %% @private 859 | is_version(<<"VERSION ", _N/binary>>) -> true; 860 | is_version(_) -> false. 861 | 862 | % VERSION is handled at server's level, so it's not a capability. 863 | %% @private 864 | is_capability(<<"VERSION ", _N/binary>>) -> 865 | false; 866 | % Checks if the capability is in the standard list. 867 | is_capability(Capability) -> 868 | lists:member(Capability, ?CAPABILITIES). 869 | 870 | % Check if a command or capability is within a capability list. 871 | %% @private 872 | is_capable(<<"GROUP", _Arg/binary>>, Capabilities) -> 873 | is_capable(<<"READER">>, Capabilities); 874 | is_capable(<<"LISTGROUP", _Arg/binary>>, Capabilities) -> 875 | is_capable(<<"READER">>, Capabilities); 876 | is_capable(<<"NEXT", _Arg/binary>>, Capabilities) -> 877 | is_capable(<<"READER">>, Capabilities); 878 | is_capable(<<"LAST", _Arg/binary>>, Capabilities) -> 879 | is_capable(<<"READER">>, Capabilities); 880 | is_capable(<<"ARTICLE", _Arg/binary>>, Capabilities) -> 881 | is_capable(<<"READER">>, Capabilities); 882 | is_capable(<<"HEAD", _Arg/binary>>, Capabilities) -> 883 | is_capable(<<"READER">>, Capabilities); 884 | is_capable(<<"BODY", _Arg/binary>>, Capabilities) -> 885 | is_capable(<<"READER">>, Capabilities); 886 | is_capable(<<"STAT", _Arg/binary>>, Capabilities) -> 887 | is_capable(<<"READER">>, Capabilities); 888 | 889 | is_capable(Capability, Capabilities) -> 890 | lists:member(Capability, Capabilities). 891 | 892 | % Join binary 893 | %% @private 894 | join(_Separator, []) -> 895 | <<>>; 896 | join(Separator, [H|T]) -> 897 | lists:foldl(fun (Value, Acc) -> 898 | <> 899 | end, H, T). 900 | 901 | % @TODO: Tail-recursion? 902 | to_binary([]) -> []; 903 | to_binary([H | T]) -> 904 | [to_binary(H) | to_binary(T)]; 905 | 906 | to_binary(Number) when is_integer(Number) -> integer_to_binary(Number); 907 | to_binary(Binary) when is_binary(Binary) -> Binary; 908 | 909 | % Full article 910 | to_binary(#{headers := Headers, body := Body}) -> 911 | join(<<"\r\n">>, [ 912 | to_binary(#{headers => Headers}), 913 | <<"">>, 914 | Body 915 | ]); 916 | % Headers only 917 | to_binary(#{headers := Headers}) -> 918 | join( 919 | <<"\r\n">>, 920 | lists:map(fun({Header, Content}) -> 921 | <
> 922 | end, maps:to_list(Headers)) 923 | ); 924 | % Body only 925 | to_binary(#{body := Body}) -> 926 | Body; 927 | 928 | to_binary(#{id := _Id}) -> 929 | <<"">>. 930 | 931 | % Converts a multi-line data block into an article. 932 | %% @private 933 | -spec to_article(binary()) -> article(). 934 | to_article(MultiLineDataBlock) when is_binary(MultiLineDataBlock) -> 935 | Lines = binary:split(string:chomp(MultiLineDataBlock), <<"\r\n">>, [global]), 936 | { HeaderLines, Body } = lists:splitwith( 937 | fun(Line) -> Line =/= <<"">> end, 938 | Lines 939 | ), 940 | 941 | Headers = maps:from_list( 942 | lists:map( 943 | fun(Line) -> 944 | [Name, Content] = binary:split(Line, <<": ">>), 945 | {Name, Content} 946 | end, 947 | HeaderLines 948 | ) 949 | ), 950 | 951 | #{ 952 | id => <<"">>, 953 | headers => Headers, 954 | body => join(<<"">>, Body) 955 | }. 956 | -------------------------------------------------------------------------------- /src/gen_nntp_internal.hrl: -------------------------------------------------------------------------------- 1 | -type state() :: term(). 2 | -type socket() :: gen_tcp:socket(). 3 | -type connect_error() :: timeout | inet:posix(). 4 | -type recv_error() :: closed | inet:posix(). 5 | -------------------------------------------------------------------------------- /test/gen_nntp_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GenNNTPTest do 2 | use GenNNTP.TestCase, async: true 3 | doctest GenNNTP, tags: [ 4 | capabilities: ["READER", "POST"], 5 | server: true, 6 | ] 7 | 8 | setup do 9 | on_exit(fn -> 10 | GenNNTP.stop(TestNNTPServer) 11 | end) 12 | 13 | :ok 14 | end 15 | 16 | describe "start/3" do 17 | 18 | test "spawns a process" do 19 | {:ok, pid} = GenNNTP.start(TestNNTPServer, [], []) 20 | assert is_pid(pid) 21 | end 22 | 23 | test "can still be called if already started" do 24 | {:ok, pid} = GenNNTP.start(TestNNTPServer, [], []) 25 | assert is_pid(pid) 26 | end 27 | 28 | end 29 | 30 | describe "stop/1" do 31 | 32 | test "stops the server by name" do 33 | {:ok, pid} = GenNNTP.start(TestNNTPServer, [], []) 34 | assert Process.alive?(pid) 35 | GenNNTP.stop(TestNNTPServer) 36 | refute Process.alive?(pid) 37 | end 38 | 39 | test "does not raise error if server is already stopped" do 40 | {:ok, _pid} = GenNNTP.start(TestNNTPServer, [], []) 41 | GenNNTP.stop(TestNNTPServer) 42 | GenNNTP.stop(TestNNTPServer) 43 | end 44 | 45 | end 46 | 47 | describe "connect/3" do 48 | 49 | @port String.to_integer(System.get_env("PORT", "119")) 50 | 51 | test "connects to a NNTP server" do 52 | GenNNTP.start(TestNNTPServer, [], []) 53 | assert {:ok, _socket, _greeting} = GenNNTP.connect("localhost", @port, []) 54 | end 55 | 56 | test "error if fail to connect to NNTP server" do 57 | assert {:error, :econnrefused} = GenNNTP.connect("localhost", @port, []) 58 | end 59 | 60 | test "receives a greeting after connecting" do 61 | GenNNTP.start(TestNNTPServer, [], []) 62 | assert {:ok, _socket, greeting} = GenNNTP.connect("localhost", @port, []) 63 | assert greeting =~ ~r/^20[0,1] / 64 | end 65 | 66 | test "connect/2 default to empty options" do 67 | GenNNTP.start(TestNNTPServer, [], []) 68 | assert {:ok, _socket, _greeting} = GenNNTP.connect("localhost", @port) 69 | end 70 | 71 | test "connect/1 default to port in PORT or 119" do 72 | GenNNTP.start(TestNNTPServer, [], []) 73 | assert {:ok, _socket, _greeting} = GenNNTP.connect("localhost") 74 | end 75 | 76 | test "connect/0 default to localhost" do 77 | GenNNTP.start(TestNNTPServer, [], []) 78 | assert {:ok, _socket, _greeting} = GenNNTP.connect() 79 | end 80 | 81 | end 82 | 83 | describe "@callback init/1" do 84 | 85 | test "is called when a client connects to it" do 86 | TestNNTPServer.start( 87 | init: fn args -> 88 | Kernel.send(:tester, {:called_back, :init, 1}) 89 | {:ok, args} 90 | end 91 | ) 92 | 93 | refute_receive( 94 | {:called_back, :init, 1}, 95 | 100, 96 | "@callback init/1 should not be called when server starts" 97 | ) 98 | 99 | {:ok, _socket, _greeting} = GenNNTP.connect() 100 | 101 | assert_receive( 102 | {:called_back, :init, 1}, 103 | 100, 104 | "@callback init/1 was not called" 105 | ) 106 | end 107 | 108 | end 109 | 110 | describe "@callback handle_CAPABILITIES/1" do 111 | 112 | setup [ 113 | :setup_CAPABILITIES, 114 | :setup_server, :setup_socket 115 | ] 116 | 117 | setup context do 118 | socket = context[:socket] 119 | 120 | unless context[:skip_command] do 121 | :ok = :gen_tcp.send(socket, "CAPABILITIES\r\n") 122 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 123 | end 124 | 125 | :ok 126 | end 127 | 128 | @tag skip_command: true 129 | test "is called when the client asks for it", %{socket: socket} do 130 | 131 | refute_receive( 132 | {:called_back, :handle_CAPABILITIES, 1}, 133 | 100, 134 | "@callback handle_CAPABILITIES/1 should not be called when client has not asked for it" 135 | ) 136 | 137 | :ok = :gen_tcp.send(socket, "CAPABILITIES\r\n") 138 | 139 | assert_receive( 140 | {:called_back, :handle_CAPABILITIES, 1}, 141 | 100, 142 | "@callback handle_CAPABILITIES/1 was not called" 143 | ) 144 | end 145 | 146 | @tag skip_command: true 147 | test "is responded with 101 code", %{socket: socket} do 148 | :ok = :gen_tcp.send(socket, "CAPABILITIES\r\n") 149 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 150 | assert response =~ ~r/^101 / 151 | end 152 | 153 | @tag capabilities: ["VERSION 2", "READER", "IHAVE", "POST", "NEWNEWS", "HDR", "OVER", "LIST", "MODE-READER"] 154 | test "is responded with capabilities returned from the callback", %{socket: socket, capabilities: capabilities} do 155 | for capability <- capabilities do 156 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 157 | assert response === "#{capability}\r\n" 158 | end 159 | 160 | # Receives the termination line. 161 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 162 | assert response === ".\r\n" 163 | 164 | # Should not receive any other message. 165 | assert {:error, :timeout} = :gen_tcp.recv(socket, 0, 100) 166 | end 167 | 168 | @tag capabilities: ["READER", "IHAVE", "POST", "NEWNEWS"] 169 | test "prepends with VERSION if not provided", %{socket: socket, capabilities: capabilities} do 170 | # Asserts that "VERSION" is always first in the response. 171 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 172 | assert response =~ ~r/^VERSION \d/ 173 | 174 | for capability <- capabilities do 175 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 176 | assert response === "#{capability}\r\n" 177 | end 178 | 179 | # Receives the termination line. 180 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 181 | assert response === ".\r\n" 182 | end 183 | 184 | @tag capabilities: ["READER", "IHAVE", "VERSION 1", "POST", "NEWNEWS"] 185 | test "moves VERSION to head", %{socket: socket, capabilities: capabilities} do 186 | # Should respond with "VERSION 1" from the callback's return. 187 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 188 | assert response === "VERSION 1\r\n" 189 | 190 | # Then the rest of the capabilities, without "VERSION 1". 191 | for capability <- capabilities, !(capability =~ ~r/^VERSION \d/) do 192 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 193 | assert response === "#{capability}\r\n" 194 | end 195 | 196 | # Receives the termination line. 197 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 198 | assert response === ".\r\n" 199 | end 200 | 201 | @tag capabilities: ["READER", "IHAVE", "AUTOUPDATE", "POST", "NEWNEWS"] 202 | test "only takes actual capabilities", %{socket: socket, capabilities: capabilities} do 203 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 204 | assert response =~ ~r/^VERSION \d/ 205 | 206 | # Should not respond with "AUTOUPDATE" since it's not standard. 207 | for capability <- capabilities, capability !== "AUTOUPDATE" do 208 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 209 | assert response === "#{capability}\r\n" 210 | end 211 | 212 | # Receives the termination line. 213 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 214 | assert response === ".\r\n" 215 | end 216 | 217 | end 218 | 219 | describe "@callback handle_GROUP/2" do 220 | # Default to have "READER" capability for "GROUP" to work. 221 | @describetag capabilities: ["READER"] 222 | 223 | setup [ 224 | :setup_groups, 225 | :setup_CAPABILITIES, :setup_GROUP, 226 | :setup_server, :setup_socket 227 | ] 228 | 229 | setup context do 230 | socket = context[:socket] 231 | 232 | unless context[:skip_command] do 233 | :ok = :gen_tcp.send(socket, "GROUP misc.test\r\n") 234 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 235 | end 236 | 237 | :ok 238 | end 239 | 240 | @tag skip_command: true 241 | test "is called when the client asks for it", %{socket: socket} do 242 | refute_receive( 243 | {:called_back, :handle_GROUP, 2}, 244 | 100, 245 | "@callback handle_GROUP/2 should not be called when client has not asked for it" 246 | ) 247 | 248 | :ok = :gen_tcp.send(socket, "GROUP misc.test\r\n") 249 | 250 | assert_receive( 251 | {:called_back, :handle_GROUP, 2}, 252 | 100, 253 | "@callback handle_GROUP/2 was not called" 254 | ) 255 | end 256 | 257 | @tag skip_command: true 258 | test "responds with `211 number low high group` when the client asks for it", context do 259 | %{socket: socket, groups: groups} = context 260 | 261 | group_name = "misc.test" 262 | {^group_name, number, low, high, _} = List.keyfind(groups, group_name, 0, false) 263 | :ok = :gen_tcp.send(socket, "GROUP #{group_name}\r\n") 264 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 265 | assert response =~ ~r/^211 #{number} #{low} #{high} #{group_name}/ 266 | end 267 | 268 | # The setup sets a list of capabilities with "READER" by default, so we empty it here. 269 | @tag skip_command: true, capabilities: [] 270 | test "is not called when there is no READER capability", %{socket: socket} do 271 | :ok = :gen_tcp.send(socket, "GROUP misc.test\r\n") 272 | 273 | refute_receive( 274 | {:called_back, :handle_GROUP, 2}, 275 | 100, 276 | "@callback handle_GROUP/2 should not be called when the server has no READER capability" 277 | ) 278 | end 279 | 280 | @tag skip_command: true, capabilities: [] 281 | test "responds with 411 when there is no READER capability", %{socket: socket} do 282 | :ok = :gen_tcp.send(socket, "GROUP misc.test\r\n") 283 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 284 | # @sntran: This is unclear to me, as the specs say nothing about this case. 285 | assert response =~ ~r/^411 / 286 | end 287 | 288 | @tag skip_command: true 289 | test "responds with 411 when the group specified is not available", %{socket: socket} do 290 | :ok = :gen_tcp.send(socket, "GROUP example.is.sob.bradner.or.barber\r\n") 291 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 292 | assert response =~ ~r/^411 / 293 | end 294 | 295 | end 296 | 297 | describe "@callback handle_LISTGROUP/2" do 298 | # Default to have "READER" capability for "LISTGROUP" to work. 299 | @describetag capabilities: ["READER"] 300 | 301 | setup [ 302 | :setup_groups, 303 | :setup_CAPABILITIES, :setup_LISTGROUP, 304 | :setup_server, :setup_socket 305 | ] 306 | 307 | test "is called when the client asks for it", %{socket: socket} do 308 | refute_receive( 309 | {:called_back, :handle_LISTGROUP, 2}, 310 | 100, 311 | "@callback handle_LISTGROUP/2 should not be called when client has not asked for it" 312 | ) 313 | 314 | :ok = :gen_tcp.send(socket, "LISTGROUP misc.test\r\n") 315 | 316 | assert_receive( 317 | {:called_back, :handle_LISTGROUP, 2}, 318 | 100, 319 | "@callback handle_LISTGROUP/2 was not called" 320 | ) 321 | end 322 | 323 | test "responds with `211 number low high group numbers` when the client asks for it", context do 324 | %{socket: socket, groups: groups} = context 325 | 326 | group_name = "misc.test" 327 | {^group_name, number, low, high, article_numbers} = List.keyfind(groups, group_name, 0, false) 328 | 329 | :ok = :gen_tcp.send(socket, "LISTGROUP #{group_name}\r\n") 330 | 331 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 332 | assert response =~ ~r/^211 #{number} #{low} #{high} #{group_name}/ 333 | 334 | # Article numbers, one per line. 335 | Enum.each(article_numbers, fn number -> 336 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 337 | assert response === "#{number}\r\n" 338 | end) 339 | 340 | # Then the termination line 341 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 342 | assert response === ".\r\n" 343 | end 344 | 345 | # The setup sets a list of capabilities with "READER" by default, so we empty it here. 346 | @tag capabilities: [] 347 | test "is not called when there is no READER capability", %{socket: socket} do 348 | :ok = :gen_tcp.send(socket, "LISTGROUP misc.test\r\n") 349 | 350 | refute_receive( 351 | {:called_back, :handle_LISTGROUP, 2}, 352 | 100, 353 | "@callback handle_LISTGROUP/2 should not be called when the server has no READER capability" 354 | ) 355 | end 356 | 357 | @tag capabilities: [] 358 | test "responds with 411 when there is no READER capability", %{socket: socket} do 359 | :ok = :gen_tcp.send(socket, "LISTGROUP misc.test\r\n") 360 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 361 | # @sntran: This is unclear to me, as the specs say nothing about this case. 362 | assert response =~ ~r/^411 / 363 | end 364 | 365 | test "responds with 411 when the group specified is not available", %{socket: socket} do 366 | :ok = :gen_tcp.send(socket, "LISTGROUP example.is.sob.bradner.or.barber\r\n") 367 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 368 | assert response =~ ~r/^411 / 369 | end 370 | 371 | test "responds with 412 when no group specified and the currently selected newsgroup is invalid", context do 372 | %{socket: socket} = context 373 | 374 | :ok = :gen_tcp.send(socket, "LISTGROUP\r\n") 375 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 376 | assert response =~ ~r/^412 / 377 | end 378 | 379 | test "responds with `211` when no group specified but currently selected newsgroup is valid", context do 380 | %{socket: socket, groups: groups} = context 381 | 382 | group_name = "misc.test" 383 | {^group_name, number, low, high, article_numbers} = List.keyfind(groups, group_name, 0, false) 384 | 385 | :ok = :gen_tcp.send(socket, "GROUP #{group_name}\r\n") 386 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 387 | 388 | :ok = :gen_tcp.send(socket, "LISTGROUP\r\n") 389 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 390 | assert response =~ ~r/^211 #{number} #{low} #{high} #{group_name}/ 391 | 392 | # Article numbers, one per line. 393 | Enum.each(article_numbers, fn number -> 394 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 395 | assert response === "#{number}\r\n" 396 | end) 397 | 398 | # Then the termination line 399 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 400 | assert response === ".\r\n" 401 | end 402 | 403 | end 404 | 405 | describe "@callback handle_NEXT/2" do 406 | # Default to have "READER" capability for "NEXT" to work. 407 | @describetag capabilities: ["READER"] 408 | 409 | setup [ 410 | :setup_articles, :setup_groups, :setup_group_articles, 411 | :setup_CAPABILITIES, :setup_GROUP, :setup_NEXT, 412 | :setup_server, :setup_socket 413 | ] 414 | 415 | # The setup sets a list of capabilities with "READER" by default, so we empty it here. 416 | @tag capabilities: [] 417 | test "is not called when there is no READER capability", context do 418 | %{socket: socket} = context 419 | 420 | :ok = :gen_tcp.send(socket, "NEXT\r\n") 421 | 422 | refute_receive( 423 | {:called_back, :handle_NEXT, 2}, 424 | 100, 425 | "@callback handle_NEXT/2 should not be called when the server has no READER capability" 426 | ) 427 | end 428 | 429 | # The setup sets a list of capabilities with "READER" by default, so we empty it here. 430 | @tag capabilities: [] 431 | test "responds with 412 when there is no READER capability", context do 432 | %{socket: socket} = context 433 | 434 | :ok = :gen_tcp.send(socket, "NEXT\r\n") 435 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 436 | # @sntran: This is unclear to me, as the specs say nothing about this case. 437 | assert response =~ ~r/^412 / 438 | end 439 | 440 | test "responds with 412 if currently selected newsgroup is invalid", context do 441 | %{socket: socket} = context 442 | 443 | :ok = :gen_tcp.send(socket, "NEXT\r\n") 444 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 445 | assert response =~ ~r/^412 / 446 | end 447 | 448 | test "is called when the client asks for it and currently selected newsgroup is valid", context do 449 | %{socket: socket} = context 450 | 451 | refute_receive( 452 | {:called_back, :handle_NEXT, 2}, 453 | 100, 454 | "@callback handle_NEXT/2 should not be called when client has not asked for it" 455 | ) 456 | 457 | group_name = "misc.test" 458 | # Calling "GROUP" should set the current article number to the first article in the group 459 | :ok = :gen_tcp.send(socket, "GROUP #{group_name}\r\n") 460 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 461 | 462 | :ok = :gen_tcp.send(socket, "NEXT\r\n") 463 | 464 | assert_receive( 465 | {:called_back, :handle_NEXT, 2}, 466 | 100, 467 | "@callback handle_NEXT/2 was not called" 468 | ) 469 | end 470 | 471 | test "responds with 420 if current article number is invalid", context do 472 | %{socket: socket, groups: groups} = context 473 | 474 | # This is the case where currently selected newsgroup is valid, but it's empty. 475 | group_name = "example.empty.newsgroup" 476 | {^group_name, 0, 0, 0, _} = List.keyfind(groups, group_name, 0, false) 477 | 478 | # Calling "GROUP" should set the current article number to the first article in the group 479 | :ok = :gen_tcp.send(socket, "GROUP #{group_name}\r\n") 480 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 481 | 482 | :ok = :gen_tcp.send(socket, "NEXT\r\n") 483 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 484 | assert response =~ ~r/^420 / 485 | end 486 | 487 | test "responds with `223 n message-id` of the next article", context do 488 | %{socket: socket, groups: groups, group_articles: group_articles} = context 489 | 490 | group_name = "misc.test" 491 | {^group_name, _estimate, low, _high, [low, next | _]} = List.keyfind(groups, group_name, 0) 492 | # Get the message id of the next article number. 493 | {_, message_id} = List.keyfind(group_articles, {next, group_name}, 0) 494 | 495 | # Calling "GROUP" should set the current article number to the first article in the group 496 | :ok = :gen_tcp.send(socket, "GROUP #{group_name}\r\n") 497 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 498 | 499 | :ok = :gen_tcp.send(socket, "NEXT\r\n") 500 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 501 | assert response =~ ~r/^223 #{next} #{message_id} / 502 | end 503 | 504 | test "responds with `223 n message-id` of the next article after next", context do 505 | %{socket: socket, groups: groups, group_articles: group_articles} = context 506 | 507 | group_name = "misc.test" 508 | {^group_name, _estimate, low, _high, [low, _next, next | _]} = List.keyfind(groups, group_name, 0) 509 | # Get the message id of the next article number. 510 | {_, message_id} = List.keyfind(group_articles, {next, group_name}, 0) 511 | 512 | # Calling "GROUP" should set the current article number to the first article in the group 513 | :ok = :gen_tcp.send(socket, "GROUP #{group_name}\r\n") 514 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 515 | 516 | :ok = :gen_tcp.send(socket, "NEXT\r\n") 517 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 518 | 519 | :ok = :gen_tcp.send(socket, "NEXT\r\n") 520 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 521 | assert response =~ ~r/^223 #{next} #{message_id} / 522 | end 523 | 524 | test "responds with 421 when current article number is already the last article of the newsgroup", context do 525 | %{socket: socket, groups: groups} = context 526 | 527 | group_name = "misc.test" 528 | {^group_name, _estimate, low, _high, [low | rest]} = List.keyfind(groups, group_name, 0) 529 | 530 | # Calling "GROUP" should set the current article number to the first article in the group 531 | :ok = :gen_tcp.send(socket, "GROUP #{group_name}\r\n") 532 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 533 | 534 | # Advance to the last article. 535 | Enum.each(rest, fn(_number) -> 536 | :ok = :gen_tcp.send(socket, "NEXT\r\n") 537 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 538 | assert response =~ ~r/^223 / 539 | end) 540 | 541 | # Because we have just switched group, the callback should return the same article number. 542 | :ok = :gen_tcp.send(socket, "NEXT\r\n") 543 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 544 | assert response =~ ~r/^421 / 545 | end 546 | 547 | end 548 | 549 | describe "@callback handle_LAST/2" do 550 | # Default to have "READER" capability for "LAST" to work. 551 | @describetag capabilities: ["READER"] 552 | 553 | setup [ 554 | :setup_articles, :setup_groups, :setup_group_articles, 555 | :setup_CAPABILITIES, :setup_GROUP, :setup_NEXT, :setup_LAST, 556 | :setup_server, :setup_socket 557 | ] 558 | 559 | # The setup sets a list of capabilities with "READER" by default, so we empty it here. 560 | @tag capabilities: [] 561 | test "is not called when there is no READER capability", context do 562 | %{socket: socket} = context 563 | 564 | :ok = :gen_tcp.send(socket, "LAST\r\n") 565 | 566 | refute_receive( 567 | {:called_back, :handle_LAST, 2}, 568 | 100, 569 | "@callback handle_LAST/2 should not be called when the server has no READER capability" 570 | ) 571 | end 572 | 573 | # The setup sets a list of capabilities with "READER" by default, so we empty it here. 574 | @tag capabilities: [] 575 | test "responds with 412 when there is no READER capability", context do 576 | %{socket: socket} = context 577 | 578 | :ok = :gen_tcp.send(socket, "LAST\r\n") 579 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 580 | # @sntran: This is unclear to me, as the specs say nothing about this case. 581 | assert response =~ ~r/^412 / 582 | end 583 | 584 | test "responds with 412 if currently selected newsgroup is invalid", context do 585 | %{socket: socket} = context 586 | 587 | :ok = :gen_tcp.send(socket, "LAST\r\n") 588 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 589 | assert response =~ ~r/^412 / 590 | end 591 | 592 | test "is called when the client asks for it and currently selected newsgroup is valid", context do 593 | %{socket: socket} = context 594 | 595 | refute_receive( 596 | {:called_back, :handle_LAST, 2}, 597 | 100, 598 | "@callback handle_LAST/2 should not be called when client has not asked for it" 599 | ) 600 | 601 | group_name = "misc.test" 602 | # Calling "GROUP" should set the current article number to the first article in the group 603 | :ok = :gen_tcp.send(socket, "GROUP #{group_name}\r\n") 604 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 605 | 606 | :ok = :gen_tcp.send(socket, "LAST\r\n") 607 | 608 | assert_receive( 609 | {:called_back, :handle_LAST, 2}, 610 | 100, 611 | "@callback handle_LAST/2 was not called" 612 | ) 613 | end 614 | 615 | test "responds with 420 if current article number is invalid", context do 616 | %{socket: socket, groups: groups} = context 617 | 618 | # This is the case where currently selected newsgroup is valid, but it's empty. 619 | group_name = "example.empty.newsgroup" 620 | {^group_name, 0, 0, 0, _} = List.keyfind(groups, group_name, 0, false) 621 | 622 | # Calling "GROUP" should set the current article number to the first article in the group 623 | :ok = :gen_tcp.send(socket, "GROUP #{group_name}\r\n") 624 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 625 | 626 | :ok = :gen_tcp.send(socket, "LAST\r\n") 627 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 628 | assert response =~ ~r/^420 / 629 | end 630 | 631 | test "responds with 422 when current article number is already the first article of the newsgroup", context do 632 | %{socket: socket} = context 633 | 634 | group_name = "misc.test" 635 | # Calling "GROUP" should set the current article number to the first article in the group 636 | :ok = :gen_tcp.send(socket, "GROUP #{group_name}\r\n") 637 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 638 | 639 | # Because we have just switched group, the callback should return the same article number. 640 | :ok = :gen_tcp.send(socket, "LAST\r\n") 641 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 642 | assert response =~ ~r/^422 / 643 | end 644 | 645 | test "responds with `223 n message-id` of the previous article", context do 646 | %{socket: socket, groups: groups, group_articles: group_articles} = context 647 | 648 | group_name = "misc.test" 649 | {^group_name, _estimate, low, _high, _numbers} = List.keyfind(groups, group_name, 0) 650 | # Get the message id of the next article number. 651 | {_, message_id} = List.keyfind(group_articles, {low, group_name}, 0) 652 | 653 | # Calling "GROUP" should set the current article number to the first article in the group 654 | :ok = :gen_tcp.send(socket, "GROUP #{group_name}\r\n") 655 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 656 | 657 | # Moves to the next article. 658 | :ok = :gen_tcp.send(socket, "NEXT\r\n") 659 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 660 | 661 | # "LAST" should move back to the first article. 662 | :ok = :gen_tcp.send(socket, "LAST\r\n") 663 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 664 | assert response =~ ~r/^223 #{low} #{message_id} / 665 | end 666 | 667 | end 668 | 669 | describe "@callback handle_ARTICLE/2" do 670 | # Default to have "READER" capability for "ARTICLE" to work. 671 | @describetag capabilities: ["READER"] 672 | 673 | setup [ 674 | :setup_articles, :setup_groups, :setup_group_articles, 675 | :setup_CAPABILITIES, :setup_GROUP, :setup_ARTICLE, 676 | :setup_server, :setup_socket 677 | ] 678 | 679 | test "is called when the client asks for it", %{socket: socket} do 680 | message_id = "<45223423@example.com>" 681 | 682 | refute_receive( 683 | {:called_back, :handle_ARTICLE, ^message_id}, 684 | 100, 685 | "@callback handle_ARTICLE/2 should not be called when client has not asked for it" 686 | ) 687 | 688 | :ok = :gen_tcp.send(socket, "ARTICLE #{message_id}\r\n") 689 | 690 | assert_receive( 691 | {:called_back, :handle_ARTICLE, ^message_id}, 692 | 100, 693 | "@callback handle_ARTICLE/2 was not called" 694 | ) 695 | end 696 | 697 | test "responds with `220 number message_id article` when the client asks for it", context do 698 | %{socket: socket, articles: articles} = context 699 | 700 | message_id = "<45223423@example.com>" 701 | :ok = :gen_tcp.send(socket, "ARTICLE #{message_id}\r\n") 702 | 703 | # The response code with number and message_id 704 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 705 | assert response === "220 0 #{message_id}\r\n" 706 | 707 | %{headers: headers, body: body} = Enum.find(articles, &(match_id(&1, message_id))) 708 | 709 | # Headers, one per line. 710 | Enum.each(headers, fn({header, content}) -> 711 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 712 | assert response === "#{header}: #{content}\r\n" 713 | end) 714 | 715 | # Then an empty line 716 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 717 | assert response === "\r\n" 718 | 719 | # Then the body 720 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 721 | assert response === "#{body}\r\n" 722 | 723 | # Then the termination line 724 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 725 | assert response === ".\r\n" 726 | 727 | end 728 | 729 | # The setup sets a list of capabilities with "READER" by default, so we empty it here. 730 | @tag capabilities: [] 731 | test "is not called when there is no READER capability", context do 732 | %{socket: socket} = context 733 | 734 | :ok = :gen_tcp.send(socket, "ARTICLE <45223423@example.com>\r\n") 735 | 736 | refute_receive( 737 | {:called_back, :handle_ARTICLE, 2}, 738 | 100, 739 | "@callback handle_ARTICLE/2 should not be called when the server has no READER capability" 740 | ) 741 | end 742 | 743 | # The setup sets a list of capabilities with "READER" by default, so we empty it here. 744 | @tag capabilities: [] 745 | test "responds with 430 when there is no READER capability", context do 746 | %{socket: socket} = context 747 | 748 | :ok = :gen_tcp.send(socket, "ARTICLE <45223423@example.com>\r\n") 749 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 750 | # @sntran: This is unclear to me, as the specs say nothing about this case. 751 | assert response =~ ~r/^430 / 752 | end 753 | 754 | test "responds with 430 when the article specified is not available", context do 755 | %{socket: socket} = context 756 | 757 | :ok = :gen_tcp.send(socket, "ARTICLE \r\n") 758 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 759 | assert response =~ ~r/^430 / 760 | end 761 | 762 | test "responds with 412 if argument is a number and the currently selected newsgroup is invalid", context do 763 | %{socket: socket} = context 764 | 765 | article_number = 3000239 766 | 767 | :ok = :gen_tcp.send(socket, "ARTICLE #{article_number}\r\n") 768 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 769 | assert response =~ ~r/^412 / 770 | end 771 | 772 | test "is also called with article number argument and currently selected newsgroup is valid", context do 773 | %{socket: socket} = context 774 | 775 | article_number = 3000239 776 | group_name = "misc.test" 777 | 778 | # Calling "GROUP" to set the current group. 779 | :ok = :gen_tcp.send(socket, "GROUP #{group_name}\r\n") 780 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 781 | 782 | refute_receive( 783 | {:called_back, :handle_ARTICLE, ^article_number}, 784 | 100, 785 | "@callback handle_ARTICLE/2 should not be called when client has not asked for it" 786 | ) 787 | 788 | :ok = :gen_tcp.send(socket, "ARTICLE #{article_number}\r\n") 789 | 790 | assert_receive( 791 | {:called_back, :handle_ARTICLE, ^article_number}, 792 | 100, 793 | "@callback handle_ARTICLE/2 was not called" 794 | ) 795 | end 796 | 797 | test "also responds with `220 number message_id article` when the client uses number argument", context do 798 | %{socket: socket, group_articles: group_articles, articles: articles} = context 799 | 800 | article_number = 3000239 801 | group_name = "misc.test" 802 | {_, message_id} = List.keyfind(group_articles, {article_number, group_name}, 0) 803 | 804 | # Calling "GROUP" to set the current group. 805 | :ok = :gen_tcp.send(socket, "GROUP #{group_name}\r\n") 806 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 807 | 808 | :ok = :gen_tcp.send(socket, "ARTICLE #{article_number}\r\n") 809 | 810 | # The response code with number and message_id 811 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 812 | assert response === "220 #{article_number} #{message_id}\r\n" 813 | 814 | %{headers: headers, body: body} = Enum.find(articles, &(match_id(&1, message_id))) 815 | 816 | # Headers, one per line. 817 | Enum.each(headers, fn({header, content}) -> 818 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 819 | assert response === "#{header}: #{content}\r\n" 820 | end) 821 | 822 | # Then an empty line 823 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 824 | assert response === "\r\n" 825 | 826 | # Then the body 827 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 828 | assert response === "#{body}\r\n" 829 | 830 | # Then the termination line 831 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 832 | assert response === ".\r\n" 833 | end 834 | 835 | test "responds with 423 if argument is a number and that article does not exist in the currently selected newsgroup", context do 836 | %{socket: socket} = context 837 | 838 | article_number = 9123212 839 | group_name = "misc.test" 840 | 841 | # Calling "GROUP" to set the current group. 842 | :ok = :gen_tcp.send(socket, "GROUP #{group_name}\r\n") 843 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 844 | 845 | :ok = :gen_tcp.send(socket, "ARTICLE #{article_number}\r\n") 846 | 847 | # The response code with number and message_id 848 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 849 | assert response =~ ~r/^423 / 850 | end 851 | 852 | test "responds with 412 if no argument specified and the currently selected newsgroup is invalid", context do 853 | %{socket: socket} = context 854 | 855 | :ok = :gen_tcp.send(socket, "ARTICLE\r\n") 856 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 857 | assert response =~ ~r/^412 / 858 | end 859 | 860 | test "responds with the article indicated by the current article number in the currently selected newsgroup if no argument specified", context do 861 | %{socket: socket, groups: groups, group_articles: group_articles} = context 862 | 863 | group_name = "misc.test" 864 | {^group_name, _number, low, _high, _} = List.keyfind(groups, group_name, 0, false) 865 | 866 | # Calling "GROUP" should set the current article number to the first article in the group 867 | :ok = :gen_tcp.send(socket, "GROUP #{group_name}\r\n") 868 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 869 | 870 | :ok = :gen_tcp.send(socket, "ARTICLE\r\n") 871 | 872 | {{article_number, ^group_name}, message_id} = List.keyfind(group_articles, {low, group_name}, 0) 873 | 874 | # The response code with number and message_id of the first article in the group. 875 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 876 | assert response === "220 #{article_number} #{message_id}\r\n" 877 | end 878 | 879 | test "responds with 420 if no argument specified and the current article number is invalid", context do 880 | %{socket: socket, groups: groups} = context 881 | 882 | # This is the case where currently selected newsgroup is valid, but it's empty. 883 | group_name = "example.empty.newsgroup" 884 | {^group_name, 0, 0, 0, _} = List.keyfind(groups, group_name, 0, false) 885 | 886 | # Calling "GROUP" should set the current article number to the first article in the group 887 | :ok = :gen_tcp.send(socket, "GROUP #{group_name}\r\n") 888 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 889 | 890 | :ok = :gen_tcp.send(socket, "ARTICLE\r\n") 891 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 892 | assert response =~ ~r/^420 / 893 | end 894 | 895 | end 896 | 897 | describe "@callback handle_HEAD/2" do 898 | # Default to have "READER" capability for "HEAD" to work. 899 | @describetag capabilities: ["READER"] 900 | 901 | setup [ 902 | :setup_articles, :setup_groups, :setup_group_articles, 903 | :setup_CAPABILITIES, :setup_GROUP, :setup_HEAD, 904 | :setup_server, :setup_socket 905 | ] 906 | 907 | test "responds with `221 number message_id article`", context do 908 | %{socket: socket, articles: articles} = context 909 | 910 | message_id = "<45223423@example.com>" 911 | :ok = :gen_tcp.send(socket, "HEAD #{message_id}\r\n") 912 | 913 | # The response code with number and message_id 914 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 915 | assert response === "221 0 #{message_id}\r\n" 916 | 917 | %{headers: headers} = Enum.find(articles, &(match_id(&1, message_id))) 918 | 919 | # Headers, one per line. 920 | Enum.each(headers, fn({header, content}) -> 921 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 922 | assert response === "#{header}: #{content}\r\n" 923 | end) 924 | 925 | # Then the termination line 926 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 927 | assert response === ".\r\n" 928 | 929 | end 930 | 931 | end 932 | 933 | describe "@acllback handle_BODY/2" do 934 | # Default to have "READER" capability for "BODY" to work. 935 | @describetag capabilities: ["READER"] 936 | 937 | setup [ 938 | :setup_articles, :setup_groups, :setup_group_articles, 939 | :setup_CAPABILITIES, :setup_GROUP, :setup_BODY, 940 | :setup_server, :setup_socket 941 | ] 942 | 943 | test "responds with `222 number message_id article`", context do 944 | %{socket: socket, articles: articles} = context 945 | 946 | message_id = "<45223423@example.com>" 947 | :ok = :gen_tcp.send(socket, "BODY #{message_id}\r\n") 948 | 949 | # The response code with number and message_id 950 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 951 | assert response === "222 0 #{message_id}\r\n" 952 | 953 | %{body: body} = Enum.find(articles, &(match_id(&1, message_id))) 954 | 955 | # Then the body 956 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 957 | assert response === "#{body}\r\n" 958 | 959 | # Then the termination line 960 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 961 | assert response === ".\r\n" 962 | 963 | end 964 | 965 | end 966 | 967 | describe "@acllback handle_STAT/2" do 968 | # Default to have "READER" capability for "STAT" to work. 969 | @describetag capabilities: ["READER"] 970 | 971 | setup [ 972 | :setup_articles, :setup_groups, :setup_group_articles, 973 | :setup_CAPABILITIES, :setup_GROUP, :setup_STAT, 974 | :setup_server, :setup_socket 975 | ] 976 | 977 | test "responds with `223 number message_id article`", context do 978 | %{socket: socket} = context 979 | 980 | message_id = "<45223423@example.com>" 981 | :ok = :gen_tcp.send(socket, "STAT #{message_id}\r\n") 982 | 983 | # The response code with number and message_id 984 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 985 | assert response === "223 0 #{message_id}\r\n" 986 | 987 | # No more response since this is not a multi-line. 988 | assert {:error, :timeout} = :gen_tcp.recv(socket, 0, 200) 989 | end 990 | 991 | end 992 | 993 | describe "@callback handle_POST/2" do 994 | @describetag capabilities: ["POST"] 995 | 996 | setup [ 997 | :setup_CAPABILITIES, :setup_POST, 998 | :setup_server, :setup_socket, 999 | ] 1000 | 1001 | test "is not called right after POST", context do 1002 | %{socket: socket} = context 1003 | 1004 | refute_receive( 1005 | {:called_back, :handle_POST, 2}, 1006 | 100, 1007 | "@callback handle_POST/2 should not be called when client has not asked for it" 1008 | ) 1009 | 1010 | :ok = :gen_tcp.send(socket, "POST\r\n") 1011 | 1012 | refute_receive( 1013 | {:called_back, :handle_POST, 2}, 1014 | 100, 1015 | "@callback handle_POST/2 should not be called when client has only sent POST" 1016 | ) 1017 | end 1018 | 1019 | test "client receives 340 from server after POST", context do 1020 | %{socket: socket} = context 1021 | 1022 | :ok = :gen_tcp.send(socket, "POST\r\n") 1023 | 1024 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 1025 | assert response =~ ~r/^340 / 1026 | end 1027 | 1028 | # Resets the capabilities to not allow posting. 1029 | @tag capabilities: [] 1030 | test "client receives 440 when the server does not allow posting", context do 1031 | %{socket: socket} = context 1032 | 1033 | :ok = :gen_tcp.send(socket, "POST\r\n") 1034 | 1035 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 1036 | assert response =~ ~r/^440 / 1037 | end 1038 | 1039 | test "is called when the client finishes sending article", context do 1040 | %{socket: socket} = context 1041 | 1042 | :ok = :gen_tcp.send(socket, "POST\r\n") 1043 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 1044 | 1045 | :ok = :gen_tcp.send( 1046 | socket, 1047 | Enum.join([ 1048 | "Message-ID: ", 1049 | "From: \"Demo User\" ", 1050 | "Newsgroups: misc.test", 1051 | "Subject: I am just a test article", 1052 | "Organization: An Example Net", 1053 | "", 1054 | "This is just a test article.", 1055 | ".", 1056 | "", 1057 | ], "\r\n") 1058 | ) 1059 | 1060 | assert_receive( 1061 | {:called_back, :handle_POST, _}, 1062 | 100, 1063 | "@callback handle_POST/2 was not called" 1064 | ) 1065 | end 1066 | 1067 | test "is called with an article map", context do 1068 | %{socket: socket} = context 1069 | 1070 | :ok = :gen_tcp.send(socket, "POST\r\n") 1071 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 1072 | 1073 | headers = %{ 1074 | "Message-ID" => "", 1075 | "From" => "\"Demo User\" ", 1076 | "Newsgroups" => "misc.test", 1077 | "Subject" => "I am just a test article", 1078 | "Organization" => "An Example Net", 1079 | } 1080 | 1081 | body = "This is just a test article." 1082 | 1083 | :ok = :gen_tcp.send( 1084 | socket, 1085 | Enum.join([ 1086 | to_line(headers), 1087 | "", 1088 | body, 1089 | ".", 1090 | "" 1091 | ], "\r\n") 1092 | ) 1093 | 1094 | assert_receive( 1095 | {:called_back, :handle_POST, %{ headers: ^headers, body: ^body }}, 1096 | 100, 1097 | "@callback handle_POST/2 was not called" 1098 | ) 1099 | end 1100 | 1101 | test "supports empty body", context do 1102 | %{socket: socket} = context 1103 | 1104 | :ok = :gen_tcp.send(socket, "POST\r\n") 1105 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 1106 | 1107 | headers = %{ 1108 | "Message-ID" => "", 1109 | "From" => "\"Demo User\" ", 1110 | "Newsgroups" => "misc.test", 1111 | "Subject" => "I am just a test article", 1112 | "Organization" => "An Example Net", 1113 | } 1114 | 1115 | body = "" 1116 | 1117 | :ok = :gen_tcp.send( 1118 | socket, 1119 | Enum.join([ 1120 | to_line(headers), 1121 | "", 1122 | body, 1123 | ".", 1124 | "" 1125 | ], "\r\n") 1126 | ) 1127 | 1128 | assert_receive( 1129 | {:called_back, :handle_POST, %{ headers: ^headers, body: ^body }}, 1130 | 100, 1131 | "@callback handle_POST/2 was not called" 1132 | ) 1133 | end 1134 | 1135 | test "supports multi-line body", context do 1136 | %{socket: socket} = context 1137 | 1138 | :ok = :gen_tcp.send(socket, "POST\r\n") 1139 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 1140 | 1141 | headers = %{ 1142 | "Message-ID" => "", 1143 | "From" => "\"Demo User\" ", 1144 | "Newsgroups" => "misc.test", 1145 | "Subject" => "I am just a test article", 1146 | "Organization" => "An Example Net", 1147 | } 1148 | 1149 | body_lines = [ 1150 | "This is just a test article that can ", 1151 | "span multiple lines, as long as it ends ", 1152 | "with a \".\"", 1153 | ] 1154 | 1155 | :ok = :gen_tcp.send( 1156 | socket, 1157 | Enum.join([ 1158 | to_line(headers), 1159 | "", 1160 | Enum.join(body_lines, "\r\n"), 1161 | ".", 1162 | "" 1163 | ], "\r\n") 1164 | ) 1165 | 1166 | assert_receive( 1167 | {:called_back, :handle_POST, %{ headers: ^headers, body: body }}, 1168 | 100, 1169 | "@callback handle_POST/2 was not called" 1170 | ) 1171 | 1172 | assert body === Enum.join(body_lines, "") 1173 | end 1174 | 1175 | test "responds with 240 if callback returns an ok-tuple", context do 1176 | %{socket: socket} = context 1177 | 1178 | :ok = :gen_tcp.send(socket, "POST\r\n") 1179 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 1180 | 1181 | headers = %{ 1182 | "Message-ID" => "", 1183 | "From" => "\"Demo User\" ", 1184 | "Newsgroups" => "misc.test", 1185 | "Subject" => "I am just a test article", 1186 | "Organization" => "An Example Net", 1187 | } 1188 | 1189 | body = "" 1190 | 1191 | :ok = :gen_tcp.send( 1192 | socket, 1193 | Enum.join([ 1194 | to_line(headers), 1195 | "", 1196 | body, 1197 | ".", 1198 | "" 1199 | ], "\r\n") 1200 | ) 1201 | 1202 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 1203 | assert response =~ ~r/^240 / 1204 | end 1205 | 1206 | test "responds with 441 to reject if callback returns an error-tuple", context do 1207 | # Here we only test an example case by rejecting the article if it does 1208 | # not have "Message-ID" header. In practive, that is totally valid, and 1209 | # it's up to the server implementation to generate the message's ID. 1210 | %{socket: socket} = context 1211 | 1212 | :ok = :gen_tcp.send(socket, "POST\r\n") 1213 | {:ok, _response} = :gen_tcp.recv(socket, 0, 1000) 1214 | 1215 | headers = %{ 1216 | "From" => "\"Demo User\" ", 1217 | "Newsgroups" => "misc.test", 1218 | "Subject" => "I am just a test article", 1219 | "Organization" => "An Example Net", 1220 | } 1221 | 1222 | body = "" 1223 | 1224 | :ok = :gen_tcp.send( 1225 | socket, 1226 | Enum.join([ 1227 | to_line(headers), 1228 | "", 1229 | body, 1230 | ".", 1231 | "" 1232 | ], "\r\n") 1233 | ) 1234 | 1235 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 1236 | assert response =~ ~r/^441 / 1237 | end 1238 | 1239 | end 1240 | 1241 | describe "DATE command" do 1242 | @describetag capabilities: ["READER"] 1243 | 1244 | setup [ 1245 | :setup_CAPABILITIES, 1246 | :setup_server, :setup_socket, 1247 | ] 1248 | 1249 | test "responds with 111", context do 1250 | %{socket: socket} = context 1251 | 1252 | :ok = :gen_tcp.send(socket, "DATE\r\n") 1253 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 1254 | 1255 | assert response =~ ~r/^111 \d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/ 1256 | end 1257 | 1258 | test "responnds with the current UTC date and time on server", context do 1259 | %{socket: socket} = context 1260 | 1261 | :ok = :gen_tcp.send(socket, "DATE\r\n") 1262 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 1263 | 1264 | now = DateTime.utc_now() 1265 | 1266 | assert response === "111 #{Calendar.strftime(now, "%Y%m%d%H%M%S")}\r\n" 1267 | end 1268 | end 1269 | 1270 | describe "@callback handle_HELP/1" do 1271 | setup do 1272 | help_text = String.trim(""" 1273 | This is some help text. There is no specific\r 1274 | formatting requirement for this test, though\r 1275 | it is customary for it to list the valid commands\r 1276 | and give a brief definition of what they do. 1277 | """) 1278 | 1279 | TestNNTPServer.start( 1280 | handle_HELP: fn(state) -> 1281 | Kernel.send(:tester, {:called_back, :handle_HELP, 1}) 1282 | 1283 | {:ok, help_text, state} 1284 | end 1285 | ) 1286 | 1287 | {:ok, socket, _greeting} = GenNNTP.connect() 1288 | 1289 | %{socket: socket, help_text: help_text} 1290 | end 1291 | 1292 | test "is called when the client asks for it", %{socket: socket} do 1293 | refute_receive( 1294 | {:called_back, :handle_HELP, 1}, 1295 | 100, 1296 | "@callback handle_HELP/1 should not be called when client has not asked for it" 1297 | ) 1298 | 1299 | :ok = :gen_tcp.send(socket, "HELP\r\n") 1300 | 1301 | assert_receive( 1302 | {:called_back, :handle_HELP, 1}, 1303 | 100, 1304 | "@callback handle_HELP/1 was not called" 1305 | ) 1306 | end 1307 | 1308 | test "responds with `100` and a multi-line data block when the client asks for it", context do 1309 | %{socket: socket, help_text: help_text} = context 1310 | 1311 | :ok = :gen_tcp.send(socket, "HELP\r\n") 1312 | 1313 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 1314 | assert response =~ ~r/^100 / 1315 | 1316 | # Help text line by line. 1317 | help_text 1318 | |> String.split("\r\n") 1319 | |> Enum.each(fn line -> 1320 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 1321 | assert response === "#{line}\r\n" 1322 | end) 1323 | 1324 | # Then the termination line 1325 | {:ok, response} = :gen_tcp.recv(socket, 0, 1000) 1326 | assert response === ".\r\n" 1327 | end 1328 | end 1329 | 1330 | describe "command/3" do 1331 | @describetag capabilities: ["READER", "IHAVE", "POST", "NEWNEWS", "HDR", "OVER", "LIST", "MODE-READER"] 1332 | 1333 | setup [ 1334 | :setup_articles, :setup_groups, :setup_group_articles, 1335 | :setup_CAPABILITIES, :setup_GROUP, :setup_ARTICLE, :setup_POST, 1336 | :setup_server, :setup_socket 1337 | ] 1338 | 1339 | test "sends QUIT command", %{socket: socket} do 1340 | assert {:ok, response} = GenNNTP.command(socket, "QUIT", []) 1341 | assert response =~ ~r/^205 / 1342 | end 1343 | 1344 | test "command/2 default to empty arguments", %{socket: socket} do 1345 | assert {:ok, response} = GenNNTP.command(socket, "QUIT") 1346 | assert response =~ ~r/^205 / 1347 | end 1348 | 1349 | test "response is trimmed/chommed", %{socket: socket} do 1350 | assert {:ok, response} = GenNNTP.command(socket, "QUIT") 1351 | refute response =~ "\r\n" 1352 | end 1353 | 1354 | test "handles multi-line response", context do 1355 | %{socket: socket, capabilities: capabilities} = context 1356 | 1357 | assert {:ok, response} = GenNNTP.command(socket, "CAPABILITIES") 1358 | assert response === """ 1359 | 101 Capability list:\r 1360 | VERSION 2\r 1361 | #{ Enum.join(capabilities, "\r\n")}\r 1362 | """ 1363 | end 1364 | 1365 | test "command/3 with arguments", context do 1366 | %{socket: socket, articles: articles} = context 1367 | 1368 | message_id = "<45223423@example.com>" 1369 | article_number = 3000239 1370 | group_name = "misc.test" 1371 | 1372 | %{headers: headers, body: body} = Enum.find(articles, &(match_id(&1, message_id))) 1373 | 1374 | assert {:ok, response} = GenNNTP.command(socket, "ARTICLE", [message_id]) 1375 | assert response === """ 1376 | 220 0 #{ message_id }\r 1377 | #{ to_line(headers) }\r 1378 | \r 1379 | #{ body }\r 1380 | """ 1381 | 1382 | # Calling "GROUP" to set the current group. 1383 | {:ok, _response} = GenNNTP.command(socket, "GROUP", [group_name]) 1384 | 1385 | assert {:ok, response} = GenNNTP.command(socket, "ARTICLE", [article_number]) 1386 | assert response === """ 1387 | 220 #{ article_number } #{ message_id }\r 1388 | #{ to_line(headers) }\r 1389 | \r 1390 | #{ body }\r 1391 | """ 1392 | 1393 | article = %{ 1394 | headers: %{ 1395 | "Message-ID" => "", 1396 | "From" => "\"Demo User\" ", 1397 | "Newsgroups" => "misc.test", 1398 | "Subject" => "I am just a test article", 1399 | "Organization" => "An Example Net", 1400 | }, 1401 | body: "This is a test article for posting" 1402 | } 1403 | assert {:ok, response} = GenNNTP.command(socket, "POST", [article]) 1404 | assert response =~ ~r/^240 / 1405 | 1406 | # Handles error with command that usually has multi-line response 1407 | assert {:ok, response} = GenNNTP.command(socket, "ARTICLE", [""]) 1408 | assert response =~ ~r/^430 / 1409 | end 1410 | 1411 | end 1412 | 1413 | describe "server interaction" do 1414 | setup do 1415 | TestNNTPServer.start() 1416 | {:ok, socket, greeting} = GenNNTP.connect() 1417 | %{socket: socket, greeting: greeting} 1418 | end 1419 | 1420 | test "MUST send a 200 greeting", %{greeting: greeting} do 1421 | assert greeting =~ ~r/^200 / 1422 | end 1423 | 1424 | test "MUST send a 205 if the client sends QUIT", %{socket: socket} do 1425 | :ok = :gen_tcp.send(socket, "QUIT\r\n") 1426 | {:ok, data} = :gen_tcp.recv(socket, 0) 1427 | assert data =~ ~r/^205 / 1428 | end 1429 | 1430 | end 1431 | 1432 | # Helpers 1433 | defp to_line(headers) when is_map(headers) do 1434 | Enum.map_join(headers, "\r\n", &to_line/1) 1435 | end 1436 | 1437 | defp to_line({k, v}), do: "#{ k }: #{ v }" 1438 | 1439 | end 1440 | -------------------------------------------------------------------------------- /test/support/test_case.ex: -------------------------------------------------------------------------------- 1 | defmodule GenNNTP.TestCase do 2 | use ExUnit.CaseTemplate 3 | 4 | using do 5 | quote do 6 | import GenNNTP.TestCase 7 | end 8 | end 9 | 10 | setup tags do 11 | # So the callbacks can send message to this test process. 12 | Process.register(self(), :tester) 13 | 14 | unless tags[:async] do 15 | end 16 | 17 | if tags[:server] do 18 | tags 19 | |> Map.to_list 20 | |> Keyword.merge(setup_CAPABILITIES(tags)) 21 | |> setup_server() 22 | end 23 | 24 | :ok 25 | end 26 | 27 | def setup_articles(context) do 28 | articles = context[:articles] || [ 29 | %{ 30 | id: "<45223423@example.com>", 31 | headers: %{ 32 | "Path" => "pathost!demo!whitehouse!not-for-mail", 33 | "From" => "'Demo User' ", 34 | "Newsgroups" => "misc.test", 35 | "Subject" => "I am just a test article", 36 | "Date" => "6 Oct 1998 04:38:40 -0500", 37 | "Organization" => "An Example Net, Uncertain, Texas", 38 | "Message-ID" => "<45223423@example.com>" 39 | }, 40 | body: "This is just a test article." 41 | }, 42 | %{ 43 | id: "<4320003@example.com>", 44 | headers: %{ 45 | "Path" => "pathost!demo!whitehouse!not-for-mail", 46 | "From" => "'Demo User' ", 47 | "Newsgroups" => "misc.test", 48 | "Subject" => "I am just a test article", 49 | "Date" => "6 Oct 1998 04:38:40 -0500", 50 | "Organization" => "An Example Net, Uncertain, Texas", 51 | "Message-ID" => "<4320003@example.com>" 52 | }, 53 | body: "This is just a test article." 54 | } 55 | ] 56 | 57 | [articles: articles] 58 | end 59 | 60 | def setup_groups(context) do 61 | [groups: context[:groups] || [ 62 | { 63 | "example.empty.newsgroup", 64 | 0, # Estimated number of articles in the group 65 | 0, # Article number of the first article in the group 66 | 0, # Article number of the last article in the group 67 | [], 68 | }, 69 | { 70 | "misc.test", 71 | 2000, # Estimated number of articles in the group 72 | 3000234, # Article number of the first article in the group 73 | 3002322, # Article number of the last article in the group 74 | [ 75 | 3000234, 76 | 3000237, 77 | 3000238, 78 | 3000239, 79 | 3002322, 80 | ], 81 | } 82 | ] 83 | ] 84 | end 85 | 86 | def setup_group_articles(context) do 87 | [group_articles: context[:group_articles] || [ 88 | {{3000234, "misc.test"}, "<4320003@example.com>"}, 89 | {{3000237, "misc.test"}, "<7320003@example.com>"}, 90 | {{3000238, "misc.test"}, "<8320003@example.com>"}, 91 | {{3000239, "misc.test"}, "<45223423@example.com>"}, 92 | {{3002322, "misc.test"}, "<2232003@example.com>"}, 93 | ] 94 | ] 95 | end 96 | 97 | def setup_CAPABILITIES(context) do 98 | [handle_CAPABILITIES: fn(state) -> 99 | Kernel.send(:tester, {:called_back, :handle_CAPABILITIES, 1}) 100 | {:ok, context[:capabilities] || [], state} 101 | end] 102 | end 103 | 104 | def setup_GROUP(context) do 105 | [handle_GROUP: fn(group, state) -> 106 | Kernel.send(:tester, {:called_back, :handle_GROUP, 2}) 107 | 108 | case List.keyfind(context[:groups], group, 0, false) do 109 | false -> {:ok, false, state} 110 | # Removes the group's article numbers. 111 | group -> {:ok, Tuple.delete_at(group, 4), state} 112 | end 113 | end] 114 | end 115 | 116 | def setup_LISTGROUP(context) do 117 | [handle_LISTGROUP: fn(group, state) -> 118 | Kernel.send(:tester, {:called_back, :handle_LISTGROUP, 2}) 119 | 120 | {:ok, List.keyfind(context[:groups], group, 0, false), state} 121 | end] 122 | end 123 | 124 | def setup_NEXT(context) do 125 | groups = context[:groups] 126 | group_articles = context[:group_articles] 127 | 128 | [handle_NEXT: fn({article_number, group}, state) -> 129 | Kernel.send(:tester, {:called_back, :handle_NEXT, 2}) 130 | 131 | # The group is guaranteed to exist here (in testing). 132 | {_, _estimate, _low, _high, numbers} = List.keyfind(groups, group, 0) 133 | # Get the first article in that newsgroup whose number is greater than 134 | # the current article number. 135 | next = Enum.find(numbers, article_number, fn(number) -> number > article_number end) 136 | # Get the message id of the next article number. 137 | {_, message_id} = List.keyfind(group_articles, {next, group}, 0) 138 | 139 | {:ok, {next, %{id: message_id}}, state} 140 | end] 141 | end 142 | 143 | def setup_LAST(context) do 144 | groups = context[:groups] 145 | group_articles = context[:group_articles] 146 | 147 | [handle_LAST: fn({article_number, group}, state) -> 148 | Kernel.send(:tester, {:called_back, :handle_LAST, 2}) 149 | 150 | # The group is guaranteed to exist here (in testing). 151 | {_, _number, _low, _high, numbers} = List.keyfind(groups, group, 0) 152 | # Get the previous article in that newsgroup whose number is less than 153 | # the current article number. 154 | prev = Enum.find(numbers, article_number, fn(number) -> number < article_number end) 155 | {_, message_id} = List.keyfind(group_articles, {prev, group}, 0) 156 | 157 | {:ok, {prev, %{id: message_id}}, state} 158 | end] 159 | end 160 | 161 | def setup_ARTICLE(context) do 162 | articles = context[:articles] 163 | group_articles = context[:group_articles] 164 | 165 | # Little helper 166 | get_article = fn(message_id, article_number) -> 167 | case Enum.find(articles, false, &(match_id(&1, message_id))) do 168 | false -> false 169 | article -> {article_number, article} 170 | end 171 | end 172 | 173 | [handle_ARTICLE: fn 174 | # Requests article by message_id. 175 | (message_id, state) when is_binary(message_id) -> 176 | Kernel.send(:tester, {:called_back, :handle_ARTICLE, message_id}) 177 | {:ok, get_article.(message_id, 0), state} 178 | 179 | # Requests article by article number 180 | ({article_number, group}, state) when is_integer(article_number) -> 181 | Kernel.send(:tester, {:called_back, :handle_ARTICLE, article_number}) 182 | 183 | case List.keyfind(group_articles, {article_number, group}, 0, false) do 184 | false -> 185 | {:ok, false, state} 186 | {_, message_id} -> 187 | {:ok, get_article.(message_id, article_number), state} 188 | end 189 | end] 190 | end 191 | 192 | def setup_HEAD(context) do 193 | articles = context[:articles] 194 | group_articles = context[:group_articles] 195 | 196 | # Little helper 197 | get_article = fn(message_id, article_number) -> 198 | case Enum.find(articles, false, &(match_id(&1, message_id))) do 199 | false -> false 200 | # Returns with the article map without the body. 201 | article -> {article_number, Map.delete(article, :body)} 202 | end 203 | end 204 | 205 | [handle_HEAD: fn 206 | # Requests article by message_id. 207 | (message_id, state) when is_binary(message_id) -> 208 | Kernel.send(:tester, {:called_back, :handle_HEAD, message_id}) 209 | {:ok, get_article.(message_id, 0), state} 210 | 211 | # Requests article by article number 212 | ({article_number, group}, state) when is_integer(article_number) -> 213 | Kernel.send(:tester, {:called_back, :handle_HEAD, article_number}) 214 | 215 | case List.keyfind(group_articles, {article_number, group}, 0, false) do 216 | false -> 217 | {:ok, false, state} 218 | {_, message_id} -> 219 | {:ok, get_article.(message_id, article_number), state} 220 | end 221 | end] 222 | end 223 | 224 | def setup_BODY(context) do 225 | articles = context[:articles] 226 | group_articles = context[:group_articles] 227 | 228 | # Little helper 229 | get_article = fn(message_id, article_number) -> 230 | case Enum.find(articles, false, &(match_id(&1, message_id))) do 231 | false -> false 232 | # Returns with the article map without the headers. 233 | article -> {article_number, Map.delete(article, :headers)} 234 | end 235 | end 236 | 237 | [handle_BODY: fn 238 | # Requests article by message_id. 239 | (message_id, state) when is_binary(message_id) -> 240 | Kernel.send(:tester, {:called_back, :handle_BODY, message_id}) 241 | {:ok, get_article.(message_id, 0), state} 242 | 243 | # Requests article by article number 244 | ({article_number, group}, state) when is_integer(article_number) -> 245 | Kernel.send(:tester, {:called_back, :handle_BODY, article_number}) 246 | 247 | case List.keyfind(group_articles, {article_number, group}, 0, false) do 248 | false -> 249 | {:ok, false, state} 250 | {_, message_id} -> 251 | {:ok, get_article.(message_id, article_number), state} 252 | end 253 | end] 254 | end 255 | 256 | def setup_STAT(context) do 257 | articles = context[:articles] 258 | group_articles = context[:group_articles] 259 | 260 | # Little helper 261 | get_article = fn(message_id, article_number) -> 262 | case Enum.find(articles, false, &(match_id(&1, message_id))) do 263 | false -> false 264 | # Returns with the article map without the headers and body. 265 | article -> {article_number, Map.drop(article, [:headers, :body])} 266 | end 267 | end 268 | 269 | [handle_STAT: fn 270 | # Requests article by message_id. 271 | (message_id, state) when is_binary(message_id) -> 272 | Kernel.send(:tester, {:called_back, :handle_STAT, message_id}) 273 | {:ok, get_article.(message_id, 0), state} 274 | 275 | # Requests article by article number 276 | ({article_number, group}, state) when is_integer(article_number) -> 277 | Kernel.send(:tester, {:called_back, :handle_STAT, article_number}) 278 | 279 | case List.keyfind(group_articles, {article_number, group}, 0, false) do 280 | false -> 281 | {:ok, false, state} 282 | {_, message_id} -> 283 | {:ok, get_article.(message_id, article_number), state} 284 | end 285 | end] 286 | end 287 | 288 | def setup_POST(_context) do 289 | [handle_POST: fn(article, state) -> 290 | Kernel.send(:tester, {:called_back, :handle_POST, article}) 291 | 292 | headers = article[:headers] 293 | 294 | # Here we reject the article if it does not have "Message-ID" header. 295 | # However, it's up to the server implementation to accept that case and 296 | # generate a UUID in place. This is just a test. 297 | case Map.get(headers, "Message-ID", nil) do 298 | nil -> 299 | {:error, "Missing Message-ID", state} 300 | _ -> 301 | {:ok, state} 302 | end 303 | 304 | end] 305 | end 306 | 307 | def setup_server(context) do 308 | TestNNTPServer.start(context) 309 | :ok 310 | end 311 | 312 | def setup_socket(_context) do 313 | {:ok, socket, greeting} = GenNNTP.connect() 314 | 315 | %{socket: socket, greeting: greeting} 316 | end 317 | 318 | def match_id(%{id: id}, id), do: true 319 | def match_id(_, _), do: false 320 | end 321 | -------------------------------------------------------------------------------- /test/support/test_nntp_server.ex: -------------------------------------------------------------------------------- 1 | defmodule TestNNTPServer do 2 | @moduledoc """ 3 | A simple NNTP server for testing purpose. 4 | 5 | In order to test various callbacks of GenNNTP without defining 6 | many different modules. 7 | 8 | To start a test server, call `start/2` with a keyword list 9 | of callbacks to test, in the form of {name, function}. 10 | """ 11 | 12 | @behaviour GenNNTP 13 | 14 | @type callback :: {atom(), fun} 15 | 16 | ## API 17 | @spec start([callback], [GenNNTP.option()]) :: :ignore | {:error, any()} | {:ok, pid()} 18 | def start(callbacks \\ [], options \\ []) do 19 | GenNNTP.start(__MODULE__, callbacks, options) 20 | end 21 | 22 | ## GenNNTP callbacks 23 | 24 | @impl GenNNTP 25 | def init(options \\ []) do 26 | # Init arguments if any. 27 | args = Access.get(options, :args) 28 | 29 | case maybe_apply(options, :init, [args], {:ok, args}) do 30 | {:ok, state} -> 31 | client = put_in(options[:state], state) 32 | {:ok, client} 33 | 34 | {:ok, state, delay} -> 35 | client = put_in(options[:state], state) 36 | {:ok, client, delay} 37 | 38 | other -> 39 | other 40 | end 41 | end 42 | 43 | @impl GenNNTP 44 | def handle_CAPABILITIES(client) do 45 | state = client[:state] 46 | 47 | case maybe_apply(client, :handle_CAPABILITIES, [state], {:ok, [], state}) do 48 | {:ok, capabilities, state} -> 49 | client = put_in(client[:state], state) 50 | {:ok, capabilities, client} 51 | other -> 52 | other 53 | end 54 | end 55 | 56 | @impl GenNNTP 57 | def handle_GROUP(group, client) do 58 | state = client[:state] 59 | 60 | case maybe_apply(client, :handle_GROUP, [group, state], {:ok, {group, 0, 0, 0}, state}) do 61 | {:ok, group_info, state} -> 62 | client = put_in(client[:state], state) 63 | {:ok, group_info, client} 64 | other -> 65 | other 66 | end 67 | end 68 | 69 | @impl GenNNTP 70 | def handle_LISTGROUP(group, client) do 71 | state = client[:state] 72 | 73 | case maybe_apply(client, :handle_LISTGROUP, [group, state], {:ok, {group, 0, 0, 0}, state}) do 74 | {:ok, group_info, state} -> 75 | client = put_in(client[:state], state) 76 | {:ok, group_info, client} 77 | other -> 78 | other 79 | end 80 | end 81 | 82 | @impl GenNNTP 83 | def handle_NEXT({article_number, _group} = arg, client) do 84 | state = client[:state] 85 | 86 | case maybe_apply(client, :handle_NEXT, [arg, state], {:ok, {article_number, %{}}, state}) do 87 | {:ok, article_info, state} -> 88 | client = put_in(client[:state], state) 89 | {:ok, article_info, client} 90 | other -> 91 | other 92 | end 93 | end 94 | 95 | @impl GenNNTP 96 | def handle_LAST({article_number, _group} = arg, client) do 97 | state = client[:state] 98 | 99 | case maybe_apply(client, :handle_LAST, [arg, state], {:ok, {article_number, %{}}, state}) do 100 | {:ok, article_info, state} -> 101 | client = put_in(client[:state], state) 102 | {:ok, article_info, client} 103 | other -> 104 | other 105 | end 106 | end 107 | 108 | @impl GenNNTP 109 | def handle_ARTICLE(arg, client) do 110 | state = client[:state] 111 | 112 | case maybe_apply(client, :handle_ARTICLE, [arg, state], {:ok, {0, {arg, %{}, ""}}, state}) do 113 | {:ok, article_info, state} -> 114 | client = put_in(client[:state], state) 115 | {:ok, article_info, client} 116 | other -> 117 | other 118 | end 119 | end 120 | 121 | @impl GenNNTP 122 | def handle_HEAD(arg, client) do 123 | state = client[:state] 124 | 125 | case maybe_apply(client, :handle_HEAD, [arg, state], {:ok, {0, {arg, %{}, ""}}, state}) do 126 | {:ok, article_info, state} -> 127 | client = put_in(client[:state], state) 128 | {:ok, article_info, client} 129 | other -> 130 | other 131 | end 132 | end 133 | 134 | @impl GenNNTP 135 | def handle_BODY(arg, client) do 136 | state = client[:state] 137 | 138 | case maybe_apply(client, :handle_BODY, [arg, state], {:ok, {0, {arg, %{}, ""}}, state}) do 139 | {:ok, article_info, state} -> 140 | client = put_in(client[:state], state) 141 | {:ok, article_info, client} 142 | other -> 143 | other 144 | end 145 | end 146 | 147 | @impl GenNNTP 148 | def handle_STAT(arg, client) do 149 | state = client[:state] 150 | 151 | case maybe_apply(client, :handle_STAT, [arg, state], {:ok, {0, {arg, %{}, ""}}, state}) do 152 | {:ok, article_info, state} -> 153 | client = put_in(client[:state], state) 154 | {:ok, article_info, client} 155 | other -> 156 | other 157 | end 158 | end 159 | 160 | @impl GenNNTP 161 | def handle_POST(article, client) do 162 | state = client[:state] 163 | 164 | case maybe_apply(client, :handle_POST, [article, state], {:ok, state}) do 165 | {:ok, state} -> 166 | client = put_in(client[:state], state) 167 | {:ok, client} 168 | {:error, reason, state} -> 169 | client = put_in(client[:state], state) 170 | {:error, reason, client} 171 | default -> 172 | default 173 | end 174 | end 175 | 176 | @impl GenNNTP 177 | def handle_HELP(client) do 178 | state = client[:state] 179 | 180 | case maybe_apply(client, :handle_HELP, [state], {:ok, "This is some help text.", state}) do 181 | {:ok, help_text, state} -> 182 | client = put_in(client[:state], state) 183 | {:ok, help_text, client} 184 | default -> 185 | default 186 | end 187 | end 188 | 189 | defp maybe_apply(server, fun, args, default_reply) do 190 | case Access.get(server, fun) do 191 | nil -> 192 | default_reply 193 | 194 | callback when is_function(callback) -> 195 | apply(callback, args) 196 | end 197 | end 198 | end 199 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start(exclude: [:skip]) 2 | --------------------------------------------------------------------------------