Generate URLs for Libravatar and Gravatar avatars

ft: first implementation of Aww

+1340
+9
.formatter.exs
··· 1 + # SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 + # 3 + # SPDX-License-Identifier: MPL-2.0 4 + 5 + # Used by "mix format" 6 + [ 7 + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 8 + line_length: 80 9 + ]
+30
.gitignore
··· 1 + # SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 + # 3 + # SPDX-License-Identifier: MPL-2.0 4 + 5 + # The directory Mix will write compiled artifacts to. 6 + /_build/ 7 + 8 + # If you run "mix test --cover", coverage assets end up here. 9 + /cover/ 10 + 11 + # The directory Mix downloads your dependencies sources to. 12 + /deps/ 13 + 14 + # Where third-party dependencies like ExDoc output generated docs. 15 + /doc/ 16 + 17 + # Ignore .fetch files in case you like to edit your project deps locally. 18 + /.fetch 19 + 20 + # If the VM crashes, it generates a dump, let's ignore it too. 21 + erl_crash.dump 22 + 23 + # Also ignore archive artifacts (built via "mix archive.build"). 24 + *.ez 25 + 26 + # Ignore package tarball (built via "mix hex.build"). 27 + aww-*.tar 28 + 29 + # Temporary files, for example, from tests. 30 + /tmp/
+373
LICENSES/MPL-2.0.txt
··· 1 + Mozilla Public License Version 2.0 2 + ================================== 3 + 4 + 1. Definitions 5 + -------------- 6 + 7 + 1.1. "Contributor" 8 + means each individual or legal entity that creates, contributes to 9 + the creation of, or owns Covered Software. 10 + 11 + 1.2. "Contributor Version" 12 + means the combination of the Contributions of others (if any) used 13 + by a Contributor and that particular Contributor's Contribution. 14 + 15 + 1.3. "Contribution" 16 + means Covered Software of a particular Contributor. 17 + 18 + 1.4. "Covered Software" 19 + means Source Code Form to which the initial Contributor has attached 20 + the notice in Exhibit A, the Executable Form of such Source Code 21 + Form, and Modifications of such Source Code Form, in each case 22 + including portions thereof. 23 + 24 + 1.5. "Incompatible With Secondary Licenses" 25 + means 26 + 27 + (a) that the initial Contributor has attached the notice described 28 + in Exhibit B to the Covered Software; or 29 + 30 + (b) that the Covered Software was made available under the terms of 31 + version 1.1 or earlier of the License, but not also under the 32 + terms of a Secondary License. 33 + 34 + 1.6. "Executable Form" 35 + means any form of the work other than Source Code Form. 36 + 37 + 1.7. "Larger Work" 38 + means a work that combines Covered Software with other material, in 39 + a separate file or files, that is not Covered Software. 40 + 41 + 1.8. "License" 42 + means this document. 43 + 44 + 1.9. "Licensable" 45 + means having the right to grant, to the maximum extent possible, 46 + whether at the time of the initial grant or subsequently, any and 47 + all of the rights conveyed by this License. 48 + 49 + 1.10. "Modifications" 50 + means any of the following: 51 + 52 + (a) any file in Source Code Form that results from an addition to, 53 + deletion from, or modification of the contents of Covered 54 + Software; or 55 + 56 + (b) any new file in Source Code Form that contains any Covered 57 + Software. 58 + 59 + 1.11. "Patent Claims" of a Contributor 60 + means any patent claim(s), including without limitation, method, 61 + process, and apparatus claims, in any patent Licensable by such 62 + Contributor that would be infringed, but for the grant of the 63 + License, by the making, using, selling, offering for sale, having 64 + made, import, or transfer of either its Contributions or its 65 + Contributor Version. 66 + 67 + 1.12. "Secondary License" 68 + means either the GNU General Public License, Version 2.0, the GNU 69 + Lesser General Public License, Version 2.1, the GNU Affero General 70 + Public License, Version 3.0, or any later versions of those 71 + licenses. 72 + 73 + 1.13. "Source Code Form" 74 + means the form of the work preferred for making modifications. 75 + 76 + 1.14. "You" (or "Your") 77 + means an individual or a legal entity exercising rights under this 78 + License. For legal entities, "You" includes any entity that 79 + controls, is controlled by, or is under common control with You. For 80 + purposes of this definition, "control" means (a) the power, direct 81 + or indirect, to cause the direction or management of such entity, 82 + whether by contract or otherwise, or (b) ownership of more than 83 + fifty percent (50%) of the outstanding shares or beneficial 84 + ownership of such entity. 85 + 86 + 2. License Grants and Conditions 87 + -------------------------------- 88 + 89 + 2.1. Grants 90 + 91 + Each Contributor hereby grants You a world-wide, royalty-free, 92 + non-exclusive license: 93 + 94 + (a) under intellectual property rights (other than patent or trademark) 95 + Licensable by such Contributor to use, reproduce, make available, 96 + modify, display, perform, distribute, and otherwise exploit its 97 + Contributions, either on an unmodified basis, with Modifications, or 98 + as part of a Larger Work; and 99 + 100 + (b) under Patent Claims of such Contributor to make, use, sell, offer 101 + for sale, have made, import, and otherwise transfer either its 102 + Contributions or its Contributor Version. 103 + 104 + 2.2. Effective Date 105 + 106 + The licenses granted in Section 2.1 with respect to any Contribution 107 + become effective for each Contribution on the date the Contributor first 108 + distributes such Contribution. 109 + 110 + 2.3. Limitations on Grant Scope 111 + 112 + The licenses granted in this Section 2 are the only rights granted under 113 + this License. No additional rights or licenses will be implied from the 114 + distribution or licensing of Covered Software under this License. 115 + Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 + Contributor: 117 + 118 + (a) for any code that a Contributor has removed from Covered Software; 119 + or 120 + 121 + (b) for infringements caused by: (i) Your and any other third party's 122 + modifications of Covered Software, or (ii) the combination of its 123 + Contributions with other software (except as part of its Contributor 124 + Version); or 125 + 126 + (c) under Patent Claims infringed by Covered Software in the absence of 127 + its Contributions. 128 + 129 + This License does not grant any rights in the trademarks, service marks, 130 + or logos of any Contributor (except as may be necessary to comply with 131 + the notice requirements in Section 3.4). 132 + 133 + 2.4. Subsequent Licenses 134 + 135 + No Contributor makes additional grants as a result of Your choice to 136 + distribute the Covered Software under a subsequent version of this 137 + License (see Section 10.2) or under the terms of a Secondary License (if 138 + permitted under the terms of Section 3.3). 139 + 140 + 2.5. Representation 141 + 142 + Each Contributor represents that the Contributor believes its 143 + Contributions are its original creation(s) or it has sufficient rights 144 + to grant the rights to its Contributions conveyed by this License. 145 + 146 + 2.6. Fair Use 147 + 148 + This License is not intended to limit any rights You have under 149 + applicable copyright doctrines of fair use, fair dealing, or other 150 + equivalents. 151 + 152 + 2.7. Conditions 153 + 154 + Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 + in Section 2.1. 156 + 157 + 3. Responsibilities 158 + ------------------- 159 + 160 + 3.1. Distribution of Source Form 161 + 162 + All distribution of Covered Software in Source Code Form, including any 163 + Modifications that You create or to which You contribute, must be under 164 + the terms of this License. You must inform recipients that the Source 165 + Code Form of the Covered Software is governed by the terms of this 166 + License, and how they can obtain a copy of this License. You may not 167 + attempt to alter or restrict the recipients' rights in the Source Code 168 + Form. 169 + 170 + 3.2. Distribution of Executable Form 171 + 172 + If You distribute Covered Software in Executable Form then: 173 + 174 + (a) such Covered Software must also be made available in Source Code 175 + Form, as described in Section 3.1, and You must inform recipients of 176 + the Executable Form how they can obtain a copy of such Source Code 177 + Form by reasonable means in a timely manner, at a charge no more 178 + than the cost of distribution to the recipient; and 179 + 180 + (b) You may distribute such Executable Form under the terms of this 181 + License, or sublicense it under different terms, provided that the 182 + license for the Executable Form does not attempt to limit or alter 183 + the recipients' rights in the Source Code Form under this License. 184 + 185 + 3.3. Distribution of a Larger Work 186 + 187 + You may create and distribute a Larger Work under terms of Your choice, 188 + provided that You also comply with the requirements of this License for 189 + the Covered Software. If the Larger Work is a combination of Covered 190 + Software with a work governed by one or more Secondary Licenses, and the 191 + Covered Software is not Incompatible With Secondary Licenses, this 192 + License permits You to additionally distribute such Covered Software 193 + under the terms of such Secondary License(s), so that the recipient of 194 + the Larger Work may, at their option, further distribute the Covered 195 + Software under the terms of either this License or such Secondary 196 + License(s). 197 + 198 + 3.4. Notices 199 + 200 + You may not remove or alter the substance of any license notices 201 + (including copyright notices, patent notices, disclaimers of warranty, 202 + or limitations of liability) contained within the Source Code Form of 203 + the Covered Software, except that You may alter any license notices to 204 + the extent required to remedy known factual inaccuracies. 205 + 206 + 3.5. Application of Additional Terms 207 + 208 + You may choose to offer, and to charge a fee for, warranty, support, 209 + indemnity or liability obligations to one or more recipients of Covered 210 + Software. However, You may do so only on Your own behalf, and not on 211 + behalf of any Contributor. You must make it absolutely clear that any 212 + such warranty, support, indemnity, or liability obligation is offered by 213 + You alone, and You hereby agree to indemnify every Contributor for any 214 + liability incurred by such Contributor as a result of warranty, support, 215 + indemnity or liability terms You offer. You may include additional 216 + disclaimers of warranty and limitations of liability specific to any 217 + jurisdiction. 218 + 219 + 4. Inability to Comply Due to Statute or Regulation 220 + --------------------------------------------------- 221 + 222 + If it is impossible for You to comply with any of the terms of this 223 + License with respect to some or all of the Covered Software due to 224 + statute, judicial order, or regulation then You must: (a) comply with 225 + the terms of this License to the maximum extent possible; and (b) 226 + describe the limitations and the code they affect. Such description must 227 + be placed in a text file included with all distributions of the Covered 228 + Software under this License. Except to the extent prohibited by statute 229 + or regulation, such description must be sufficiently detailed for a 230 + recipient of ordinary skill to be able to understand it. 231 + 232 + 5. Termination 233 + -------------- 234 + 235 + 5.1. The rights granted under this License will terminate automatically 236 + if You fail to comply with any of its terms. However, if You become 237 + compliant, then the rights granted under this License from a particular 238 + Contributor are reinstated (a) provisionally, unless and until such 239 + Contributor explicitly and finally terminates Your grants, and (b) on an 240 + ongoing basis, if such Contributor fails to notify You of the 241 + non-compliance by some reasonable means prior to 60 days after You have 242 + come back into compliance. Moreover, Your grants from a particular 243 + Contributor are reinstated on an ongoing basis if such Contributor 244 + notifies You of the non-compliance by some reasonable means, this is the 245 + first time You have received notice of non-compliance with this License 246 + from such Contributor, and You become compliant prior to 30 days after 247 + Your receipt of the notice. 248 + 249 + 5.2. If You initiate litigation against any entity by asserting a patent 250 + infringement claim (excluding declaratory judgment actions, 251 + counter-claims, and cross-claims) alleging that a Contributor Version 252 + directly or indirectly infringes any patent, then the rights granted to 253 + You by any and all Contributors for the Covered Software under Section 254 + 2.1 of this License shall terminate. 255 + 256 + 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 + end user license agreements (excluding distributors and resellers) which 258 + have been validly granted by You or Your distributors under this License 259 + prior to termination shall survive termination. 260 + 261 + ************************************************************************ 262 + * * 263 + * 6. Disclaimer of Warranty * 264 + * ------------------------- * 265 + * * 266 + * Covered Software is provided under this License on an "as is" * 267 + * basis, without warranty of any kind, either expressed, implied, or * 268 + * statutory, including, without limitation, warranties that the * 269 + * Covered Software is free of defects, merchantable, fit for a * 270 + * particular purpose or non-infringing. The entire risk as to the * 271 + * quality and performance of the Covered Software is with You. * 272 + * Should any Covered Software prove defective in any respect, You * 273 + * (not any Contributor) assume the cost of any necessary servicing, * 274 + * repair, or correction. This disclaimer of warranty constitutes an * 275 + * essential part of this License. No use of any Covered Software is * 276 + * authorized under this License except under this disclaimer. * 277 + * * 278 + ************************************************************************ 279 + 280 + ************************************************************************ 281 + * * 282 + * 7. Limitation of Liability * 283 + * -------------------------- * 284 + * * 285 + * Under no circumstances and under no legal theory, whether tort * 286 + * (including negligence), contract, or otherwise, shall any * 287 + * Contributor, or anyone who distributes Covered Software as * 288 + * permitted above, be liable to You for any direct, indirect, * 289 + * special, incidental, or consequential damages of any character * 290 + * including, without limitation, damages for lost profits, loss of * 291 + * goodwill, work stoppage, computer failure or malfunction, or any * 292 + * and all other commercial damages or losses, even if such party * 293 + * shall have been informed of the possibility of such damages. This * 294 + * limitation of liability shall not apply to liability for death or * 295 + * personal injury resulting from such party's negligence to the * 296 + * extent applicable law prohibits such limitation. Some * 297 + * jurisdictions do not allow the exclusion or limitation of * 298 + * incidental or consequential damages, so this exclusion and * 299 + * limitation may not apply to You. * 300 + * * 301 + ************************************************************************ 302 + 303 + 8. Litigation 304 + ------------- 305 + 306 + Any litigation relating to this License may be brought only in the 307 + courts of a jurisdiction where the defendant maintains its principal 308 + place of business and such litigation shall be governed by laws of that 309 + jurisdiction, without reference to its conflict-of-law provisions. 310 + Nothing in this Section shall prevent a party's ability to bring 311 + cross-claims or counter-claims. 312 + 313 + 9. Miscellaneous 314 + ---------------- 315 + 316 + This License represents the complete agreement concerning the subject 317 + matter hereof. If any provision of this License is held to be 318 + unenforceable, such provision shall be reformed only to the extent 319 + necessary to make it enforceable. Any law or regulation which provides 320 + that the language of a contract shall be construed against the drafter 321 + shall not be used to construe this License against a Contributor. 322 + 323 + 10. Versions of the License 324 + --------------------------- 325 + 326 + 10.1. New Versions 327 + 328 + Mozilla Foundation is the license steward. Except as provided in Section 329 + 10.3, no one other than the license steward has the right to modify or 330 + publish new versions of this License. Each version will be given a 331 + distinguishing version number. 332 + 333 + 10.2. Effect of New Versions 334 + 335 + You may distribute the Covered Software under the terms of the version 336 + of the License under which You originally received the Covered Software, 337 + or under the terms of any subsequent version published by the license 338 + steward. 339 + 340 + 10.3. Modified Versions 341 + 342 + If you create software not governed by this License, and you want to 343 + create a new license for such software, you may create and use a 344 + modified version of this License if you rename the license and remove 345 + any references to the name of the license steward (except to note that 346 + such modified license differs from this License). 347 + 348 + 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 + Licenses 350 + 351 + If You choose to distribute Source Code Form that is Incompatible With 352 + Secondary Licenses under the terms of this version of the License, the 353 + notice described in Exhibit B of this License must be attached. 354 + 355 + Exhibit A - Source Code Form License Notice 356 + ------------------------------------------- 357 + 358 + This Source Code Form is subject to the terms of the Mozilla Public 359 + License, v. 2.0. If a copy of the MPL was not distributed with this 360 + file, You can obtain one at https://mozilla.org/MPL/2.0/. 361 + 362 + If it is not possible or desirable to put the notice in a particular 363 + file, then You may include the notice in a location (such as a LICENSE 364 + file in a relevant directory) where a recipient would be likely to look 365 + for such a notice. 366 + 367 + You may add additional accurate notices of copyright ownership. 368 + 369 + Exhibit B - "Incompatible With Secondary Licenses" Notice 370 + --------------------------------------------------------- 371 + 372 + This Source Code Form is "Incompatible With Secondary Licenses", as 373 + defined by the Mozilla Public License, v. 2.0.
+41
README.md
··· 1 + <!-- 2 + SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 3 + 4 + SPDX-License-Identifier: MPL-2.0 5 + --> 6 + 7 + # Aww 8 + 9 + Generate URL for avatars using: 10 + 11 + - [Gravatar](https://gravatar.com) 12 + - [Libravatar](https://libravatar.org) 13 + 14 + ## Installation 15 + 16 + ```elixir 17 + def deps do 18 + [ 19 + {:aww, "~> 1.0"} 20 + ] 21 + end 22 + ``` 23 + 24 + ## Usage 25 + 26 + Just pass the e-mail (or, in case of Libravatar, you can also use OpenID URL) to 27 + the `Aww.avatar_url/1` function and you will receive `URI` struct which points 28 + to avatar for given user. 29 + 30 + ```elixir 31 + Aww.avatar_url("~@hauleth.dev") 32 + # => %URI{ 33 + # scheme: "https", 34 + # userinfo: nil, 35 + # host: "seccdn.libravatar.org", 36 + # port: nil, 37 + # path: "/avatar/ac3c4f9061d8e8252d03301685433aa8f943d4d6540caa232ccfb227b38aaa6c", 38 + # query: "", 39 + # fragment: nil 40 + # } 41 + ```
+9
config/config.exs
··· 1 + # SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 + # 3 + # SPDX-License-Identifier: MPL-2.0 4 + 5 + import Config 6 + 7 + if Mix.env() == :test do 8 + config :aww, :default, "avatar.example.com" 9 + end
+237
lib/aww.ex
··· 1 + # SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 + # 3 + # SPDX-License-Identifier: MPL-2.0 4 + 5 + defmodule Aww do 6 + alias Aww.Cache 7 + 8 + require Logger 9 + 10 + @default_cache Aww.Cache 11 + 12 + @type host() :: 13 + :gravatar 14 + | :libravatar 15 + | (String.t(), opts() -> String.t() | {String.t(), integer()}) 16 + | {module(), atom(), [term()]} 17 + 18 + @type avatar_opt() :: 19 + {:size, integer()} 20 + | {:default, String.t()} 21 + | {:robohash, String.t()} 22 + | {:forcedefault?, boolean()} 23 + | {:rating, String.t()} 24 + 25 + @type service_opt() :: 26 + {:host, host()} 27 + | {:secure?, boolean()} 28 + | {:resolv_opts, [:inet_res.res_option()]} 29 + | {:timeout, pos_integer()} 30 + | {atom(), term()} 31 + 32 + @type opts() :: [avatar_opt() | {:service_opts, [service_opt()]}] 33 + 34 + @doc """ 35 + Generate URL for avatar using given service. 36 + 37 + ## Options 38 + 39 + Gravatar options supported are: 40 + 41 + - `:size` - pixel size of the resulting image. As returned avatars are always 42 + squares, only one side is needed. 43 + - `:default` - fallback image and/or implementation for image generation. 44 + Check out your implementation documentation for list of supported values. 45 + - `:robohash` - Libravatar and Gravatar support using additional option for 46 + `default: :robohash`. 47 + - `:forcedefault?` - force using default image (useful for testing) 48 + - `:rating` - require image of this rating or lower. Used for explicit image 49 + filtering. Ignored by Libravatar (that service requires all images to be 50 + `PG` rated). 51 + 52 + ### Service options 53 + 54 + It is possible to configure used service via options under `:service_opts` 55 + configuration key. Recognised options are: 56 + 57 + - `:host` - implementation that will be used for generating avatar. Possible 58 + options are: 59 + + `:gravatar` - [Gravatar](https://gravatar.com) 60 + + `:libravatar` - [Libravatar](https://www.libravatar.org) - federated 61 + and open source service providing Gravatar-compatible API 62 + + 2-ary function passed either as a capture or `{m, f, a}` tuple. 63 + In case of MFA tuple the arguments will be prepended to passed list. 64 + Arguments appended are: domain of the `email` and `opts` passed to this 65 + function. 66 + Defaults to `Application.get_env(:aww, :default, :libravatar)`. 67 + - `:secure?` - whether to use secure (HTTPS) connection to the service. 68 + Defaults to `true`. 69 + - `:resolv_opts` - used by `host: :libravatar` - additional opts passed to DNS 70 + resolver. See `t::inet_res.res_option/0` for details. 71 + - `:timeout` - DNS query timeout in milliseconds. Defaults to `5000` (5 seconds). 72 + """ 73 + @spec avatar_url(String.t(), keyword()) :: URI.t() 74 + def avatar_url(email, opts \\ []) do 75 + {service_opts, avatar_opts} = Keyword.pop(opts, :service_opts, []) 76 + default_host = Application.fetch_env!(:aww, :default) 77 + secure? = Keyword.get(service_opts, :secure?, true) 78 + {domain, normalized} = normalize(email) 79 + 80 + {host, port} = 81 + case host(service_opts[:host] || default_host, domain, service_opts) do 82 + {host, port} -> {host, port} 83 + host when is_binary(host) -> {host, nil} 84 + end 85 + 86 + hash = :crypto.hash(:sha256, normalized) 87 + 88 + %URI{ 89 + scheme: if(secure?, do: "https", else: "http"), 90 + host: host, 91 + port: port, 92 + path: "/avatar/#{Base.encode16(hash, case: :lower)}", 93 + query: query(avatar_opts) 94 + } 95 + end 96 + 97 + defp host(kind, domain, opts) 98 + 99 + defp host(:gravatar, _domain, opts) do 100 + if Keyword.get(opts, :secure?, true) do 101 + "secure.gravatar.com" 102 + else 103 + "gravatar.com" 104 + end 105 + end 106 + 107 + defp host(host, _domain, _opts) when is_binary(host), do: {host, nil} 108 + defp host(%URI{host: host, port: port}, _domain, _opts), do: {host, port} 109 + 110 + defp host(:libravatar, "", opts) do 111 + if Keyword.get(opts, :secure?, true) do 112 + "seccdn.libravatar.org" 113 + else 114 + "cdn.libravatar.org" 115 + end 116 + end 117 + 118 + defp host(:libravatar, domain, opts) do 119 + secure? = Keyword.get(opts, :secure?, true) 120 + cache = Keyword.get(opts, :cache, @default_cache) 121 + 122 + {fallback, query} = 123 + if secure? do 124 + {"seccdn.libravatar.org", "_avatars-sec._tcp.#{domain}"} 125 + else 126 + { 127 + "cdn.libravatar.org", 128 + "_avatars._tcp.#{domain}" 129 + } 130 + end 131 + 132 + Cache.get_or_store(cache, query, fn _ -> 133 + resolv_opts = opts[:resolv_opts] || [] 134 + timeout = opts[:timeout] || 5000 135 + 136 + defederated = 137 + opts[:defederated] || Application.fetch_env!(:aww, :defederated) 138 + 139 + case dns_resp(query, resolv_opts, timeout) do 140 + {:ok, ttl, {host, _} = result} -> 141 + if host in defederated do 142 + Logger.debug( 143 + "Ignoring avatar from #{host}, because it was defederated" 144 + ) 145 + 146 + {fallback, 60_000} 147 + else 148 + {result, :timer.seconds(ttl)} 149 + end 150 + 151 + _ -> 152 + {fallback, 60_000} 153 + end 154 + end) 155 + end 156 + 157 + defp host({m, f, a}, domain, opts) 158 + when is_atom(m) and is_atom(f) and is_list(a) do 159 + apply(m, f, [domain, opts | a]) 160 + end 161 + 162 + defp host(func, domain, opts) when is_function(func, 2) do 163 + func.(domain, opts) 164 + end 165 + 166 + defp query(opts) do 167 + size = opts[:size] || opts[:s] 168 + default = opts[:default] || opts[:d] 169 + robohash = opts[:robohash] 170 + force = (opts[:forcedefault?] || opts[:f]) && "y" 171 + rating = opts[:rating] || opts[:r] 172 + 173 + [ 174 + s: size, 175 + d: default, 176 + f: force, 177 + r: rating, 178 + robohash: robohash 179 + ] 180 + |> Enum.filter(&elem(&1, 1)) 181 + |> Map.new() 182 + |> URI.encode_query() 183 + end 184 + 185 + defp normalize(input) do 186 + trimmed = String.trim(input) 187 + 188 + case String.downcase(trimmed) do 189 + "http://" <> _ -> 190 + normalize_oauth(trimmed) 191 + 192 + "https://" <> _ -> 193 + normalize_oauth(trimmed) 194 + 195 + other -> 196 + domain = split_last(other, "@") 197 + {domain, other} 198 + end 199 + end 200 + 201 + defp normalize_oauth(input) do 202 + uri = URI.parse(input) 203 + host = String.downcase(uri.host) 204 + 205 + {host, URI.to_string(%URI{uri | host: host})} 206 + end 207 + 208 + defp split_last(input, pattern) when is_binary(input) do 209 + case :string.find(input, pattern, :trailing) do 210 + ^pattern <> rest -> rest 211 + _ -> "" 212 + end 213 + end 214 + 215 + defp dns_resp(host, opts, timeout) do 216 + with {:ok, msg} <- 217 + :inet_res.resolve( 218 + to_charlist(host), 219 + :in, 220 + :srv, 221 + opts, 222 + timeout 223 + ), 224 + [rr | _] <- :inet_dns.msg(msg, :anlist) do 225 + ttl = :inet_dns.rr(rr, :ttl) 226 + {_, _, port, host} = :inet_dns.rr(rr, :data) 227 + 228 + {:ok, ttl, {to_string(host), port}} 229 + else 230 + _ -> 231 + :error 232 + end 233 + catch 234 + _, _ -> 235 + :error 236 + end 237 + end
+22
lib/aww/application.ex
··· 1 + # SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 + # 3 + # SPDX-License-Identifier: MPL-2.0 4 + 5 + defmodule Aww.Application do 6 + @moduledoc false 7 + 8 + use Application 9 + 10 + @impl true 11 + def start(_mode, _opts) do 12 + children = [ 13 + {Aww.Cache, name: Aww.Cache} 14 + ] 15 + 16 + opts = [ 17 + strategy: :one_for_one 18 + ] 19 + 20 + Supervisor.start_link(children, opts) 21 + end 22 + end
+87
lib/aww/cache.ex
··· 1 + # SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 + # 3 + # SPDX-License-Identifier: MPL-2.0 4 + 5 + defmodule Aww.Cache do 6 + @moduledoc false 7 + 8 + # Super simple cache used for resolving DNS queries, to not wait on each 9 + # single name resolution. 10 + 11 + use GenServer 12 + 13 + require Logger 14 + 15 + def start_link(opts) do 16 + opts = %{ 17 + name: Keyword.fetch!(opts, :name), 18 + cleanup: opts[:cleanup] || 60_000 19 + } 20 + 21 + GenServer.start_link(__MODULE__, opts) 22 + end 23 + 24 + def get_or_store(cache, key, fun) do 25 + case get(cache, key) do 26 + {:ok, value} -> 27 + value 28 + 29 + :error -> 30 + {value, ttl} = fun.(key) 31 + store(cache, key, value, ttl) 32 + 33 + value 34 + end 35 + end 36 + 37 + def store(cache, key, result, ttl) do 38 + timestamp = 39 + System.monotonic_time() + 40 + :erlang.convert_time_unit(ttl, :millisecond, :native) 41 + 42 + :ets.insert(cache, {key, result, timestamp}) 43 + end 44 + 45 + def get(cache, key) do 46 + timestamp = System.monotonic_time() 47 + 48 + case :ets.lookup(cache, key) do 49 + [{_, result, ttl}] when ttl > timestamp -> 50 + {:ok, result} 51 + 52 + _ -> 53 + :error 54 + end 55 + end 56 + 57 + def clean(cache) do 58 + :ets.delete_all_objects(cache) 59 + end 60 + 61 + @impl true 62 + def init(opts) do 63 + state = opts 64 + 65 + _tid = :ets.new(state.name, [:named_table, :public]) 66 + 67 + Process.send_after(self(), :cleanup, state.cleanup) 68 + 69 + {:ok, state} 70 + end 71 + 72 + @impl true 73 + def handle_info(:cleanup, state) do 74 + timestamp = System.monotonic_time() 75 + 76 + count = 77 + :ets.select_delete(state.name, [ 78 + {{:_, :_, :"$1"}, [{:"=<", :"$1", timestamp}], [true]} 79 + ]) 80 + 81 + Logger.debug("Deleted #{count} cached entries") 82 + 83 + Process.send_after(self(), :cleanup, state.cleanup) 84 + 85 + {:noreply, state} 86 + end 87 + end
+53
mix.exs
··· 1 + # SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 + # 3 + # SPDX-License-Identifier: MPL-2.0 4 + 5 + defmodule Aww.MixProject do 6 + use Mix.Project 7 + 8 + def project do 9 + [ 10 + app: :aww, 11 + version: "1.0.0", 12 + elixir: "~> 1.16", 13 + start_permanent: Mix.env() == :prod, 14 + elixirc_paths: elixirc_paths(Mix.env()), 15 + test_coverage: [ 16 + ignore_modules: [ 17 + AwwTest.Dns 18 + ] 19 + ], 20 + docs: [ 21 + formatters: ~w[html], 22 + extras: [ 23 + "README.md" 24 + ], 25 + main: "readme" 26 + ], 27 + deps: deps() 28 + ] 29 + end 30 + 31 + defp elixirc_paths(:test), do: ["lib", "test/support"] 32 + defp elixirc_paths(_), do: ["lib"] 33 + 34 + # Run "mix help compile.app" to learn about applications. 35 + def application do 36 + [ 37 + extra_applications: [:logger], 38 + mod: {Aww.Application, []}, 39 + env: [ 40 + default: :libravatar, 41 + defederated: [] 42 + ] 43 + ] 44 + end 45 + 46 + # Run "mix help deps" to learn about dependencies. 47 + defp deps do 48 + [ 49 + {:ex_doc, ">= 0.0.0", only: [:dev, :test]}, 50 + {:stream_data, "~> 1.1", only: [:test]} 51 + ] 52 + end 53 + end
+9
mix.lock
··· 1 + %{ 2 + "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 3 + "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, 4 + "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 5 + "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 6 + "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 7 + "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 8 + "stream_data": {:hex, :stream_data, "1.1.1", "fd515ca95619cca83ba08b20f5e814aaf1e5ebff114659dc9731f966c9226246", [:mix], [], "hexpm", "45d0cd46bd06738463fd53f22b70042dbb58c384bb99ef4e7576e7bb7d3b8c8c"}, 9 + }
+3
mix.lock.license
··· 1 + SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 + 3 + SPDX-License-Identifier: MPL-2.0
+85
test/aww/cache_test.exs
··· 1 + # SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 + # 3 + # SPDX-License-Identifier: MPL-2.0 4 + 5 + defmodule Aww.CacheTest do 6 + use ExUnit.Case, async: true 7 + use ExUnitProperties 8 + 9 + @subject Aww.Cache 10 + 11 + doctest @subject 12 + 13 + setup ctx do 14 + opts = 15 + Keyword.merge( 16 + [ 17 + name: ctx.test, 18 + cleanup: 300 19 + ], 20 + ctx[:cache_opts] || [] 21 + ) 22 + 23 + start_supervised!({@subject, opts}) 24 + 25 + {:ok, cache: ctx.test} 26 + end 27 + 28 + property "stored value can be retrieved", ctx do 29 + check all( 30 + key <- string(:ascii), 31 + value <- string(:ascii) 32 + ) do 33 + @subject.store(ctx.cache, key, value, 5000) 34 + 35 + assert {:ok, value} == @subject.get(ctx.cache, key) 36 + end 37 + end 38 + 39 + test "after TTL duration value is no longer returned", ctx do 40 + key = "foo" 41 + value = "bar" 42 + 43 + @subject.store(ctx.cache, key, value, 100) 44 + 45 + assert {:ok, value} == @subject.get(ctx.cache, key) 46 + 47 + Process.sleep(111) 48 + 49 + assert :error == @subject.get(ctx.cache, key) 50 + end 51 + 52 + describe "get_or_store/3" do 53 + test "function is called only once if repeatedly called with the same key", 54 + ctx do 55 + this = self() 56 + 57 + fun = fn _key -> 58 + send(this, :called) 59 + 60 + {1, 200} 61 + end 62 + 63 + key = "foo" 64 + 65 + @subject.get_or_store(ctx.cache, key, fun) 66 + @subject.get_or_store(ctx.cache, key, fun) 67 + @subject.get_or_store(ctx.cache, key, fun) 68 + 69 + assert_received :called 70 + refute_received :called 71 + end 72 + end 73 + 74 + describe "eviction" do 75 + test "after cleanup timeout old entries are removed from cache", ctx do 76 + @subject.store(ctx.cache, "foo", "bar", 100) 77 + 78 + refute [] == :ets.tab2list(ctx.cache) 79 + 80 + Process.sleep(310) 81 + 82 + assert [] == :ets.tab2list(ctx.cache) 83 + end 84 + end 85 + end
+25
test/aww/gravatar_test.exs
··· 1 + # SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 + # 3 + # SPDX-License-Identifier: MPL-2.0 4 + 5 + defmodule Aww.GravatarTest do 6 + use ExUnit.Case, async: true 7 + 8 + @subject Aww 9 + 10 + test "Gravatar implementation points to secure.gravatar.com" do 11 + url = 12 + @subject.avatar_url("foo@example.com", service_opts: [host: :gravatar]) 13 + 14 + assert url.host =~ "secure.gravatar.com" 15 + end 16 + 17 + test "Gravatar implementation points to gravatar.com (insecure)" do 18 + url = 19 + @subject.avatar_url("foo@example.com", 20 + service_opts: [host: :gravatar, secure?: false] 21 + ) 22 + 23 + assert url.host =~ "gravatar.com" 24 + end 25 + end
+110
test/aww/libravatar_test.exs
··· 1 + # SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 + # 3 + # SPDX-License-Identifier: MPL-2.0 4 + 5 + defmodule Aww.LibravatarTest do 6 + use ExUnit.Case, async: true 7 + 8 + @subject Aww 9 + 10 + defmodule Dns do 11 + @behaviour AwwTest.Dns 12 + 13 + @impl true 14 + def handle_query("_avatars-sec._tcp.evil.test", :in, :srv) do 15 + %{ 16 + ttl: 3600, 17 + data: {0, 0, 0xBEEF, ~c"defederated.example"} 18 + } 19 + end 20 + 21 + def handle_query("_avatars-sec._tcp.example.test", :in, :srv) do 22 + %{ 23 + ttl: 3600, 24 + data: {0, 0, 0xBEEF, ~c"secure.example"} 25 + } 26 + end 27 + 28 + def handle_query("_avatars._tcp.example.test", :in, :srv) do 29 + %{ 30 + ttl: 3600, 31 + data: {0, 0, 0xDEAD, ~c"insecure.example"} 32 + } 33 + end 34 + end 35 + 36 + setup do 37 + assert {:ok, pid, ns} = start_supervised({AwwTest.Dns, module: Dns}) 38 + 39 + on_exit(fn -> 40 + Aww.Cache.clean(Aww.Cache) 41 + end) 42 + 43 + {:ok, pid: pid, ns: ns} 44 + end 45 + 46 + test "uses secure SRV data", %{ns: ns} do 47 + url = 48 + @subject.avatar_url("foo@example.test", 49 + service_opts: [ 50 + host: :libravatar, 51 + resolv_opts: [nameservers: [ns]] 52 + ] 53 + ) 54 + 55 + assert url.host == "secure.example" 56 + assert url.port == 0xBEEF 57 + end 58 + 59 + test "uses insecure SRV data", %{ns: ns} do 60 + url = 61 + @subject.avatar_url("foo@example.test", 62 + service_opts: [ 63 + secure?: false, 64 + host: :libravatar, 65 + resolv_opts: [nameservers: [ns]] 66 + ] 67 + ) 68 + 69 + assert url.host == "insecure.example" 70 + assert url.port == 0xDEAD 71 + end 72 + 73 + test "defederated host results in fallback", %{ns: ns} do 74 + url = 75 + @subject.avatar_url("foo@evil.test", 76 + service_opts: [ 77 + host: :libravatar, 78 + defederated: ["defederated.example"], 79 + resolv_opts: [nameservers: [ns]] 80 + ] 81 + ) 82 + 83 + assert url.host == "seccdn.libravatar.org" 84 + end 85 + 86 + test "with no response uses secure fallback", %{ns: ns} do 87 + url = 88 + @subject.avatar_url("foo@example.invalid", 89 + service_opts: [ 90 + host: :libravatar, 91 + resolv_opts: [nameservers: [ns]] 92 + ] 93 + ) 94 + 95 + assert url.host == "seccdn.libravatar.org" 96 + end 97 + 98 + test "with no response uses insecure fallback", %{ns: ns} do 99 + url = 100 + @subject.avatar_url("foo@example.invalid", 101 + service_opts: [ 102 + secure?: false, 103 + host: :libravatar, 104 + resolv_opts: [nameservers: [ns]] 105 + ] 106 + ) 107 + 108 + assert url.host == "cdn.libravatar.org" 109 + end 110 + end
+155
test/aww_test.exs
··· 1 + # SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 + # 3 + # SPDX-License-Identifier: MPL-2.0 4 + 5 + defmodule AwwTest do 6 + use ExUnit.Case, async: true 7 + use ExUnitProperties 8 + 9 + @subject Aww 10 + 11 + doctest @subject 12 + 13 + test "by default there is no query" do 14 + url = @subject.avatar_url("foo@example.com") 15 + 16 + assert "" == url.query 17 + end 18 + 19 + test "by default HTTPS is used" do 20 + url = @subject.avatar_url("foo@example.com") 21 + 22 + assert "https" == url.scheme 23 + end 24 + 25 + test "can opt out to insecure HTTP query" do 26 + url = @subject.avatar_url("foo@example.com", service_opts: [secure?: false]) 27 + 28 + assert "http" == url.scheme 29 + end 30 + 31 + test "default can be changed" do 32 + url = @subject.avatar_url("foo@example.com", default: "monster") 33 + 34 + assert url.query =~ "d=monster" 35 + end 36 + 37 + test "size can be defined" do 38 + url = @subject.avatar_url("foo@example.com", size: 2137) 39 + 40 + assert url.query =~ "s=2137" 41 + end 42 + 43 + test "enforce default" do 44 + url = @subject.avatar_url("foo@example.com", forcedefault?: true) 45 + 46 + assert url.query =~ "f=y" 47 + end 48 + 49 + test "select rating" do 50 + url = @subject.avatar_url("foo@example.com", rating: "R") 51 + 52 + assert url.query =~ "r=R" 53 + end 54 + 55 + property "email is case insensitive" do 56 + check all( 57 + local_part <- string(:ascii, min_length: 1), 58 + domain <- string(:ascii, min_length: 1) 59 + ) do 60 + input = "#{local_part}@#{domain}" 61 + downcased = String.downcase(input) 62 + 63 + assert @subject.avatar_url(input) == @subject.avatar_url(downcased) 64 + end 65 + end 66 + 67 + property "custom host contains that host" do 68 + check all(host <- string(:ascii, min_lenght: 1)) do 69 + url = @subject.avatar_url("foo@example.com", service_opts: [host: host]) 70 + 71 + assert host == url.host 72 + end 73 + end 74 + 75 + property "custom host as URI contains that host" do 76 + check all(host <- string(:ascii, min_lenght: 1), port <- integer(0..0xFFFF)) do 77 + uri = %URI{ 78 + host: host, 79 + port: port 80 + } 81 + 82 + url = @subject.avatar_url("foo@example.com", service_opts: [host: uri]) 83 + 84 + assert host == url.host 85 + assert port == url.port 86 + end 87 + end 88 + 89 + defp domain do 90 + part = string([?a..?z, ?A..?Z, ?0..?9, ?-], min_length: 1, max_length: 10) 91 + 92 + gen( 93 + all( 94 + parts <- list_of(part, min_length: 1, max_length: 4), 95 + do: Enum.join(parts, ".") 96 + ) 97 + ) 98 + end 99 + 100 + defp open_id_uri do 101 + gen all( 102 + scheme <- one_of([constant("http"), constant("https")]), 103 + host <- domain(), 104 + port <- integer(0..0xFFFF), 105 + segment = string(:alphanumeric, min_length: 1), 106 + path <- list_of(segment), 107 + user <- string(:alphanumeric, min_lenght: 1), 108 + pass <- string(:alphanumeric, min_length: 1) 109 + ) do 110 + %URI{ 111 + scheme: scheme, 112 + host: host, 113 + port: port, 114 + path: "/" <> Enum.join(path, "/"), 115 + userinfo: "#{user}:#{pass}" 116 + } 117 + end 118 + end 119 + 120 + property "normalizes OpenID addresses" do 121 + check all(url <- open_id_uri()) do 122 + input = URI.to_string(url) 123 + 124 + downcased = 125 + URI.to_string(%URI{ 126 + url 127 + | host: String.downcase(url.host) 128 + }) 129 + 130 + assert @subject.avatar_url(input) == @subject.avatar_url(downcased) 131 + end 132 + end 133 + 134 + describe "function as a host provider" do 135 + def host_func(host, _opts), do: "test.#{host}" 136 + 137 + test "passed as MFA" do 138 + url = 139 + @subject.avatar_url("hello@example.com", 140 + service_opts: [host: {__MODULE__, :host_func, []}] 141 + ) 142 + 143 + assert url.host == "test.example.com" 144 + end 145 + 146 + test "passed as capture" do 147 + url = 148 + @subject.avatar_url("hello@example.com", 149 + service_opts: [host: &host_func/2] 150 + ) 151 + 152 + assert url.host == "test.example.com" 153 + end 154 + end 155 + end
+87
test/support/dns.ex
··· 1 + # SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 + # 3 + # SPDX-License-Identifier: MPL-2.0 4 + 5 + defmodule AwwTest.Dns do 6 + use GenServer 7 + 8 + @callback handle_query( 9 + String.t(), 10 + :inet_req.dns_class(), 11 + :inet_req.dns_rr_type() 12 + ) :: map() 13 + 14 + def start_link(module: mod) do 15 + with {:ok, pid} <- GenServer.start_link(__MODULE__, mod) do 16 + {:ok, pid, ns(pid)} 17 + end 18 + end 19 + 20 + def ns(pid), do: GenServer.call(pid, :name) 21 + 22 + def init(mod) do 23 + {:ok, sock} = :gen_udp.open(0, mode: :binary) 24 + 25 + {:ok, %{sock: sock, mod: mod}} 26 + end 27 + 28 + def handle_call(:name, _ref, state) do 29 + {:ok, name} = :inet.sockname(state.sock) 30 + 31 + {:reply, name, state} 32 + end 33 + 34 + def handle_info({:udp, sock, from_ip, from_port, data}, %{sock: sock} = state) do 35 + with {:ok, req} <- :inet_dns.decode(data) do 36 + req = :inet_dns.msg(req) 37 + header = :inet_dns.header(req[:header]) 38 + questions = req[:qdlist] 39 + 40 + answers = 41 + for q <- questions, 42 + query = :inet_dns.dns_query(q), 43 + answer <- handle(state, query) do 44 + :inet_dns.make_rr(answer) 45 + end 46 + 47 + resp_header = 48 + :inet_dns.make_header( 49 + id: header[:id], 50 + aa: false, 51 + qr: true, 52 + rd: true, 53 + ra: true, 54 + opcode: :query, 55 + rcode: 0 56 + ) 57 + 58 + resp = 59 + :inet_dns.make_msg( 60 + header: resp_header, 61 + qdlist: questions, 62 + anlist: answers 63 + ) 64 + 65 + :gen_udp.send(sock, from_ip, from_port, :inet_dns.encode(resp)) 66 + end 67 + 68 + {:noreply, state} 69 + end 70 + 71 + defp handle(%{mod: mod}, query) do 72 + domain = query[:domain] 73 + class = query[:class] 74 + type = query[:type] 75 + 76 + resp = 77 + try do 78 + mod.handle_query(List.to_string(domain), class, type) 79 + catch 80 + _, _ -> [] 81 + end 82 + 83 + for %{ttl: ttl, data: data} <- List.wrap(resp) do 84 + [domain: domain, type: type, class: class, ttl: ttl, data: data] 85 + end 86 + end 87 + end
+5
test/test_helper.exs
··· 1 + # SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 + # 3 + # SPDX-License-Identifier: MPL-2.0 4 + 5 + ExUnit.start()