Merge 1907c26735
into be60263d47
This commit is contained in:
commit
26d8c71d57
|
@ -0,0 +1,28 @@
|
|||
%%%----------------------------------------------------------------------
|
||||
%%% File : s3_util.erl
|
||||
%%% Usage : S3 URL Generation and Signing
|
||||
%%% Author : Roman Hargrave <roman@hargrave.info>
|
||||
%%% Purpose : Signing AWS Requests. Intended for S3-CS use.
|
||||
%%% Created : 24 Aug 2022 by Roman Hargrave <roman@hargrave.info>
|
||||
%%%
|
||||
%%%
|
||||
%%% This program is free software; you can redistribute it and/or
|
||||
%%% modify it under the terms of the GNU General Public License as
|
||||
%%% published by the Free Software Foundation; either version 2 of the
|
||||
%%% License, or (at your option) any later version.
|
||||
%%%
|
||||
%%% This program is distributed in the hope that it will be useful,
|
||||
%%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
%%% General Public License for more details.
|
||||
%%%
|
||||
%%% You should have received a copy of the GNU General Public License along
|
||||
%%% with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
%%%----------------------------------------------------------------------
|
||||
|
||||
-record(aws_auth, {access_key_id :: binary(),
|
||||
access_key :: binary(),
|
||||
region :: binary()}).
|
||||
|
||||
-define(AWS_SERVICE_S3, <<"s3">>).
|
|
@ -0,0 +1,256 @@
|
|||
%%%----------------------------------------------------------------------
|
||||
%%% File : aws_util.erl
|
||||
%%% Usage : AWS URL Signing
|
||||
%%% Author : Roman Hargrave <roman@hargrave.info>
|
||||
%%% Purpose : Signing AWS Requests. Intended for S3-CS use.
|
||||
%%% Created : 24 Aug 2022 by Roman Hargrave <roman@hargrave.info>
|
||||
%%%
|
||||
%%%
|
||||
%%% This program is free software; you can redistribute it and/or
|
||||
%%% modify it under the terms of the GNU General Public License as
|
||||
%%% published by the Free Software Foundation; either version 2 of the
|
||||
%%% License, or (at your option) any later version.
|
||||
%%%
|
||||
%%% This program is distributed in the hope that it will be useful,
|
||||
%%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
%%% General Public License for more details.
|
||||
%%%
|
||||
%%% You should have received a copy of the GNU General Public License along
|
||||
%%% with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
%%%----------------------------------------------------------------------
|
||||
|
||||
%% URL Signing. Documented at
|
||||
%% https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html
|
||||
|
||||
-module(aws_util).
|
||||
-author("roman@hargrave.info").
|
||||
|
||||
-include("aws.hrl").
|
||||
|
||||
-type verb() :: get | put | post | delete.
|
||||
-type headers() :: [{unicode:chardata(), unicode:chardata()}].
|
||||
-type query_list() :: [{unicode:chardata(), unicode:chardata() | true}].
|
||||
-type ttl() :: 1..604800.
|
||||
|
||||
-define(AWS_SIGN_ALGO, <<"AWS4-HMAC-SHA256">>).
|
||||
|
||||
-import(crypto, [mac/4]).
|
||||
-import(uri_string, [compose_query/1,
|
||||
dissect_query/1]).
|
||||
-import(misc, [crypto_hmac/3]).
|
||||
|
||||
-export([signed_url/7]).
|
||||
|
||||
%%------------------------------------------------------------------------
|
||||
%% API
|
||||
%%------------------------------------------------------------------------
|
||||
|
||||
-spec signed_url(
|
||||
Auth :: #aws_auth{},
|
||||
Verb :: verb(),
|
||||
Service :: binary(),
|
||||
Url :: binary(),
|
||||
ExtraHeaders :: headers(),
|
||||
Time :: calendar:datetime(),
|
||||
TTL :: ttl()
|
||||
) ->
|
||||
SignedUrl :: binary().
|
||||
% sign a URL given headers, a verb, authentication details, and a time
|
||||
signed_url(Auth, Verb, Service, URL, ExtraHeaders, Time, TTL) ->
|
||||
#{host := Host} = UnauthenticatedUriMap = uri_string:parse(URL),
|
||||
Headers = [{<<"host">>, Host} | ExtraHeaders],
|
||||
% insert authentication params.
|
||||
QueryList = sorted_query_list(uri_query_list(UnauthenticatedUriMap)
|
||||
++ base_query_params(Auth, Time, Service, Headers, TTL)),
|
||||
UriMap = UnauthenticatedUriMap#{query => compose_query(QueryList)},
|
||||
% generate and sign the message
|
||||
StringToSign = string_to_sign(Auth, Time, Service, Verb, UriMap, Headers),
|
||||
SigningKey = signing_key(Auth, Time, Service),
|
||||
Signature = encode_hex(crypto_hmac(sha256, SigningKey, StringToSign)),
|
||||
% add signature to the query list and compose URI
|
||||
SignedQueryString = compose_query([{<<"X-Amz-Signature">>, Signature}|QueryList]),
|
||||
uri_string:recompose(UriMap#{query => SignedQueryString}).
|
||||
|
||||
%%------------------------------------------------------------------------
|
||||
%% Internal
|
||||
%%------------------------------------------------------------------------
|
||||
|
||||
-spec sorted_query_list(
|
||||
QueryList :: query_list()
|
||||
) ->
|
||||
SortedQueryList :: query_list().
|
||||
% sort a query paramater list by parameter name, ascending
|
||||
sorted_query_list(QueryList) ->
|
||||
lists:sort(fun ({L, _}, {R, _}) -> L =< R end, QueryList).
|
||||
|
||||
-spec uri_query_list(
|
||||
UriMap :: uri_string:uri_map()
|
||||
) ->
|
||||
QueryList :: query_list().
|
||||
% extract a query list from a uri_map().
|
||||
uri_query_list(#{query := QueryString}) ->
|
||||
dissect_query(QueryString);
|
||||
uri_query_list(_) ->
|
||||
[].
|
||||
|
||||
-spec verb(
|
||||
Verb :: verb()
|
||||
) ->
|
||||
binary().
|
||||
% convert a verb atom to a binary list
|
||||
verb(get) ->
|
||||
<<"GET">>;
|
||||
verb(put) ->
|
||||
<<"PUT">>;
|
||||
verb(post) ->
|
||||
<<"POST">>;
|
||||
verb(delete) ->
|
||||
<<"DELETE">>.
|
||||
|
||||
-spec encode_hex(
|
||||
Data :: binary()
|
||||
) ->
|
||||
EncodedData :: binary().
|
||||
% lowercase binary:encode_hex
|
||||
encode_hex(Data) ->
|
||||
str:to_lower(str:to_hexlist(Data)).
|
||||
|
||||
-spec iso8601_timestamp_utc(
|
||||
DateTime :: calendar:datetime()
|
||||
) ->
|
||||
Timestamp :: binary().
|
||||
% Generate an ISO8601-like YmdTHMSZ timestamp for X-Amz-Date. Only
|
||||
% produces UTC ('Z') timestamps. No separators.
|
||||
iso8601_timestamp_utc({{Y, Mo, D}, {H, M, S}}) ->
|
||||
str:format("~B~2..0B~2..0BT~2..0B~2..0B~2..0BZ",
|
||||
[Y, Mo, D,
|
||||
H, M, S]).
|
||||
|
||||
-spec iso8601_date(
|
||||
DateTime :: calendar:datetime()
|
||||
) ->
|
||||
DateStr :: binary().
|
||||
% ISO8601 formatted date, no separators.
|
||||
iso8601_date({{Y, M, D}, _}) ->
|
||||
str:format("~B~2..0B~2..0B", [Y, M, D]).
|
||||
|
||||
-spec scope(
|
||||
Auth :: #aws_auth{},
|
||||
Time :: calendar:datetime(),
|
||||
Service :: binary()
|
||||
) ->
|
||||
Scope :: binary().
|
||||
% Generate the request scope used in the credential field and signature message
|
||||
scope(#aws_auth{region = Region},
|
||||
Time,
|
||||
Service) ->
|
||||
str:format("~ts/~ts/~ts/aws4_request",
|
||||
[iso8601_date(Time),
|
||||
Region,
|
||||
Service]).
|
||||
|
||||
-spec credential(
|
||||
Auth :: #aws_auth{},
|
||||
Time :: calendar:datetime(),
|
||||
Service :: binary()
|
||||
) ->
|
||||
Auth :: binary().
|
||||
% Generate the value used for X-Amz-Credential
|
||||
credential(#aws_auth{access_key_id = KeyID} = Auth,
|
||||
Time,
|
||||
Service) ->
|
||||
str:format("~ts/~ts", [KeyID, scope(Auth, Time, Service)]).
|
||||
|
||||
-spec base_query_params(
|
||||
Auth :: #aws_auth{},
|
||||
Time :: calendar:datetime(),
|
||||
Service :: binary(),
|
||||
Headers :: headers(),
|
||||
TTL :: ttl()
|
||||
) ->
|
||||
BaseQueryParams :: [{unicode:chardata(), unicode:chardata()}].
|
||||
% Return the minimum required set of query parameters needed for
|
||||
% authenticated signed requests.
|
||||
base_query_params(Auth, Time, Service, Headers, TTL) ->
|
||||
[{<<"X-Amz-Algorithm">>, ?AWS_SIGN_ALGO},
|
||||
{<<"X-Amz-Credential">>, credential(Auth, Time, Service)},
|
||||
{<<"X-Amz-Date">>, iso8601_timestamp_utc(Time)},
|
||||
{<<"X-Amz-Expires">>, erlang:integer_to_binary(TTL)},
|
||||
{<<"X-Amz-SignedHeaders">>, signed_headers(Headers)}].
|
||||
|
||||
-spec canonical_headers(
|
||||
Headers :: headers()
|
||||
) ->
|
||||
CanonicalHeaders :: unicode:chardata().
|
||||
% generate the header list for canonical_request
|
||||
canonical_headers(Headers) ->
|
||||
str:join(lists:map(fun ({Name, Value}) ->
|
||||
str:format("~ts:~ts~n", [Name, Value])
|
||||
end, Headers),
|
||||
<<>>).
|
||||
|
||||
-spec signed_headers(
|
||||
SignedHeaders :: headers()
|
||||
) ->
|
||||
SignedHeaders :: unicode:chardata().
|
||||
% generate a semicolon-delimited list of headers, used to enumerate
|
||||
% signed headers in the AWSv4 canonical request
|
||||
signed_headers(SignedHeaders) ->
|
||||
str:join(lists:map(fun ({Name, _}) ->
|
||||
Name
|
||||
end, SignedHeaders),
|
||||
<<";">>).
|
||||
|
||||
-spec canonical_request(
|
||||
Verb :: verb(),
|
||||
UriMap :: uri_string:uri_map(),
|
||||
Headers :: headers()
|
||||
) ->
|
||||
CanonicalRequest :: unicode:chardata().
|
||||
% Generate the canonical request used to compute the signature
|
||||
canonical_request(Verb,
|
||||
#{query := Query,
|
||||
path := Path},
|
||||
Headers) ->
|
||||
<<(verb(Verb))/binary, "\n",
|
||||
Path/binary, "\n",
|
||||
Query/binary, "\n",
|
||||
(canonical_headers(Headers))/binary, "\n",
|
||||
(signed_headers(Headers))/binary, "\n",
|
||||
"UNSIGNED-PAYLOAD">>.
|
||||
|
||||
-spec string_to_sign(
|
||||
Auth :: #aws_auth{},
|
||||
Time :: calendar:datetime(),
|
||||
Service :: binary(),
|
||||
Verb :: verb(),
|
||||
UriMap :: uri_string:uri_map(),
|
||||
Headers :: headers()
|
||||
) ->
|
||||
StringToSign :: unicode:chardata().
|
||||
% generate the "string to sign", as per AWS specs
|
||||
string_to_sign(Auth, Time, Service, Verb, UriMap, Headers) ->
|
||||
RequestHash = crypto:hash(sha256, canonical_request(Verb, UriMap, Headers)),
|
||||
<<?AWS_SIGN_ALGO/binary, "\n",
|
||||
(iso8601_timestamp_utc(Time))/binary, "\n",
|
||||
(scope(Auth, Time, Service))/binary, "\n",
|
||||
(encode_hex(RequestHash))/binary>>.
|
||||
|
||||
-spec signing_key(
|
||||
Auth :: #aws_auth{},
|
||||
Time :: calendar:datetime(),
|
||||
Service :: binary()
|
||||
) ->
|
||||
SigningKey :: binary().
|
||||
% generate the signing key used in the final HMAC-SHA256 round for
|
||||
% request signing.
|
||||
signing_key(#aws_auth{access_key = AccessKey,
|
||||
region = Region},
|
||||
Time,
|
||||
Service) ->
|
||||
DateKey = crypto_hmac(sha256, <<"AWS4", AccessKey/binary>>, iso8601_date(Time)),
|
||||
DateRegionKey = crypto_hmac(sha256, DateKey, Region),
|
||||
DateRegionServiceKey = crypto_hmac(sha256, DateRegionKey, Service),
|
||||
crypto_hmac(sha256, DateRegionServiceKey, <<"aws4_request">>).
|
|
@ -0,0 +1,462 @@
|
|||
%%%----------------------------------------------------------------------
|
||||
%%% File : mod_s3_upload.erl
|
||||
%%% Author : Roman Hargrave <roman@hargrave.info>
|
||||
%%% Purpose : An XEP-0363 Implementation using S3-compatible storage
|
||||
%%% Created : 24 Aug 2022 by Roman Hargrave <roman@hargrave.info>
|
||||
%%%
|
||||
%%%
|
||||
%%% This program is free software; you can redistribute it and/or
|
||||
%%% modify it under the terms of the GNU General Public License as
|
||||
%%% published by the Free Software Foundation; either version 2 of the
|
||||
%%% License, or (at your option) any later version.
|
||||
%%%
|
||||
%%% This program is distributed in the hope that it will be useful,
|
||||
%%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
%%% General Public License for more details.
|
||||
%%%
|
||||
%%% You should have received a copy of the GNU General Public License along
|
||||
%%% with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
%%%----------------------------------------------------------------------
|
||||
|
||||
-module(mod_s3_upload).
|
||||
-author('roman@hargrave.info').
|
||||
|
||||
-behaviour(gen_mod).
|
||||
-behaviour(gen_server).
|
||||
|
||||
-protocol({xep, 363, '1.1.0'}).
|
||||
|
||||
-include("logger.hrl").
|
||||
-include("translate.hrl").
|
||||
-include("aws.hrl").
|
||||
|
||||
-include_lib("xmpp/include/xmpp.hrl").
|
||||
|
||||
% gen_mod callbacks
|
||||
-export([start/2,
|
||||
stop/1,
|
||||
reload/3,
|
||||
depends/2,
|
||||
mod_opt_type/1,
|
||||
mod_options/1,
|
||||
mod_doc/0]).
|
||||
|
||||
% gen_server callbacks
|
||||
-export([init/1,
|
||||
handle_info/2,
|
||||
handle_call/3,
|
||||
handle_cast/2]).
|
||||
|
||||
-import(gen_mod, [get_opt/2]).
|
||||
|
||||
%%-----------------------------------------------------------------------
|
||||
%% gen_mod callbacks and related machinery
|
||||
%%-----------------------------------------------------------------------
|
||||
|
||||
-spec start(
|
||||
ServerHost :: binary(),
|
||||
Opts :: gen_mod:opts()
|
||||
) ->
|
||||
Result :: {ok, pid()} | {error, term()}.
|
||||
%
|
||||
start(ServerHost, Opts) ->
|
||||
gen_mod:start_child(?MODULE, ServerHost, Opts).
|
||||
|
||||
-spec stop(
|
||||
ServerHost :: binary()
|
||||
) ->
|
||||
Result :: any().
|
||||
%
|
||||
stop(ServerHost) ->
|
||||
gen_mod:stop_child(?MODULE, ServerHost).
|
||||
|
||||
-spec reload(
|
||||
ServerHost :: binary(),
|
||||
NewOpts :: gen_mod:opts(),
|
||||
OldOpts :: gen_mod:opts()
|
||||
) ->
|
||||
Result :: ok.
|
||||
%
|
||||
reload(ServerHost, NewOpts, _OldOpts) ->
|
||||
ServerRef = gen_mod:get_module_proc(ServerHost, ?MODULE),
|
||||
% cast a message to the server with the new options
|
||||
gen_server:cast(ServerRef, {reload,
|
||||
ServerHost,
|
||||
build_service_params(ServerHost, NewOpts)}).
|
||||
|
||||
%%------------------------------------------------------------------------
|
||||
%% Options
|
||||
%%------------------------------------------------------------------------
|
||||
|
||||
-spec mod_opt_type(
|
||||
OptionName :: atom()
|
||||
) ->
|
||||
OptionType :: econf:validator().
|
||||
%
|
||||
mod_opt_type(access_key_id) ->
|
||||
econf:binary();
|
||||
mod_opt_type(access_key_secret) ->
|
||||
econf:binary();
|
||||
mod_opt_type(region) ->
|
||||
econf:binary();
|
||||
mod_opt_type(bucket_url) ->
|
||||
econf:url([http, https]);
|
||||
mod_opt_type(max_size) ->
|
||||
econf:pos_int(infinity);
|
||||
mod_opt_type(set_public) ->
|
||||
econf:bool();
|
||||
mod_opt_type(put_ttl) ->
|
||||
econf:pos_int(infinity);
|
||||
mod_opt_type(service_name) ->
|
||||
econf:binary();
|
||||
mod_opt_type(hosts) ->
|
||||
econf:hosts();
|
||||
mod_opt_type(access) ->
|
||||
econf:acl().
|
||||
|
||||
-spec mod_options(
|
||||
Host :: binary()
|
||||
) ->
|
||||
Options :: [{atom(), term()} | atom()].
|
||||
%
|
||||
mod_options(Host) ->
|
||||
[{access_key_id, undefined},
|
||||
{access_key_secret, undefined},
|
||||
{region, undefined},
|
||||
{bucket_url, undefined},
|
||||
{max_size, 1073741824},
|
||||
{set_public, true},
|
||||
{put_ttl, 600},
|
||||
{service_name, <<"S3 Upload">>},
|
||||
{hosts, [<<"upload.", Host/binary>>]},
|
||||
{access, local}].
|
||||
|
||||
-spec mod_doc() ->
|
||||
Doc :: #{desc => binary() | [binary()],
|
||||
opts => [{atom(), #{value := binary(), desc := binary()}}]}.
|
||||
%
|
||||
mod_doc() ->
|
||||
#{desc =>
|
||||
[?T("This module implements XEP-0363 using an S3 bucket "
|
||||
"instead of an internal web server. This simplifies "
|
||||
"clustered deployments by removing the need to maintain "
|
||||
"shared storage, and is in many cases less expensive "
|
||||
"byte-for-byte than block storage. It is mutually "
|
||||
"incompatible with mod_http_upload.")],
|
||||
opts =>
|
||||
[{access_key_id,
|
||||
#{value => ?T("AccessKeyId"),
|
||||
desc => ?T("AWS Access Key ID.")}},
|
||||
{access_key_secret,
|
||||
#{value => ?T("AccessKeySecret"),
|
||||
desc => ?T("AWS Access Key Secret.")}},
|
||||
{region,
|
||||
#{value => ?T("Region"),
|
||||
desc => ?T("AWS Region")}},
|
||||
{bucket_url,
|
||||
#{value => ?T("BucketUrl"),
|
||||
desc => ?T("S3 Bucket URL.")}},
|
||||
{max_size,
|
||||
#{value => ?T("MaxSize"),
|
||||
desc => ?T("Maximum file size, in bytes. 0 is unlimited.")}},
|
||||
{set_public,
|
||||
#{value => ?T("SetPublic"),
|
||||
desc => ?T("Set x-amz-acl to public-read.")}},
|
||||
{put_ttl,
|
||||
#{value => ?T("PutTtl"),
|
||||
desc => ?T("How long the PUT URL will be valid for.")}},
|
||||
{service_name,
|
||||
#{value => ?T("ServiceName"),
|
||||
desc => ?T("Name given in discovery requests.")}},
|
||||
{hosts, % named for consistency with other modules
|
||||
#{value => ?T("ServiceJids"),
|
||||
desc => ?T("JIDs used when communicating with the service")}},
|
||||
{access,
|
||||
#{value => ?T("UploadAccess"),
|
||||
desc => ?T("Access rule for JIDs that may request new URLs")}}]}.
|
||||
|
||||
depends(_Host, _Opts) ->
|
||||
[].
|
||||
|
||||
%%------------------------------------------------------------------------
|
||||
%% gen_server callbacks.
|
||||
%%------------------------------------------------------------------------
|
||||
|
||||
-record(params,
|
||||
{service_name :: binary(), % name given for the service in discovery.
|
||||
service_jids :: [binary()], % stanzas destined for these JIDs will be routed to the service.
|
||||
max_size :: integer() | infinity, % maximum upload size. sort of the honor system in this case.
|
||||
bucket_url :: binary(), % S3 bucket URL or subdomain
|
||||
set_public :: boolean(), % set the public-read ACL on the object?
|
||||
ttl :: integer(), % TTL of the signed PUT URL
|
||||
server_host :: binary(), % XMPP vhost the service belongs to
|
||||
auth :: #aws_auth{},
|
||||
access :: atom()}).
|
||||
|
||||
-spec init(
|
||||
Params :: list()
|
||||
) ->
|
||||
Result :: {ok, #params{}}.
|
||||
%
|
||||
init([ServerHost, Opts]) ->
|
||||
Params = build_service_params(ServerHost, Opts),
|
||||
update_routes(ServerHost, [], Params#params.service_jids),
|
||||
{ok, Params}.
|
||||
|
||||
-spec handle_info(
|
||||
Message :: any(),
|
||||
State :: #params{}
|
||||
) ->
|
||||
Result :: {noreply, #params{}}.
|
||||
% receive non-standard (gen_server) messages
|
||||
handle_info({route, #iq{lang = Lang} = Packet}, Opts) ->
|
||||
try xmpp:decode_els(Packet) of
|
||||
IQ ->
|
||||
ejabberd_router:route(handle_iq(IQ, Opts)),
|
||||
{noreply, Opts}
|
||||
catch _:{xmpp_codec, Why} ->
|
||||
Message = xmpp:io_format_error(Why),
|
||||
Error = xmpp:err_bad_request(Message, Lang),
|
||||
ejabberd_router:route_error(Packet, Error),
|
||||
{noreply, Opts}
|
||||
end;
|
||||
handle_info(Request, Opts) ->
|
||||
?WARNING_MSG("Unexpected info: ~p", [Request]),
|
||||
{noreply, Opts}.
|
||||
|
||||
-spec handle_call(
|
||||
Request:: any(),
|
||||
Sender :: gen_server:from(),
|
||||
State :: #params{}
|
||||
) ->
|
||||
Result :: {noreply, #params{}}.
|
||||
% respond to $gen_call messages
|
||||
handle_call(Request, Sender, Opts) ->
|
||||
?WARNING_MSG("Unexpected call from ~p: ~p", [Sender, Request]),
|
||||
{noreply, Opts}.
|
||||
|
||||
-spec handle_cast(
|
||||
Request :: any(),
|
||||
State :: #params{}
|
||||
) ->
|
||||
Result :: {noreply, #params{}}.
|
||||
% receive $gen_cast messages
|
||||
handle_cast({reload, ServerHost, NewOpts}, OldOpts) ->
|
||||
update_routes(ServerHost,
|
||||
OldOpts#params.service_jids,
|
||||
NewOpts#params.service_jids),
|
||||
{noreply, NewOpts};
|
||||
handle_cast(Request, Opts) ->
|
||||
?WARNING_MSG("Unexpected cast: ~p", [Request]),
|
||||
{noreply, Opts}.
|
||||
|
||||
%%------------------------------------------------------------------------
|
||||
%% Internal Stanza Processing
|
||||
%%-----------------------------------------------------------------------
|
||||
|
||||
-spec update_routes(
|
||||
ServerHost :: binary(),
|
||||
OldJIDs :: [binary()],
|
||||
NewJIDs :: [binary()]
|
||||
) ->
|
||||
Result :: _.
|
||||
% maintain routing rules for JIDs owned by this service.
|
||||
update_routes(ServerHost, OldJIDs, NewJIDs) ->
|
||||
lists:foreach(fun (Domain) ->
|
||||
ejabberd_router:register_route(Domain, ServerHost)
|
||||
end, NewJIDs),
|
||||
lists:foreach(fun ejabberd_router:unregister_route/1, OldJIDs -- NewJIDs).
|
||||
|
||||
|
||||
-spec handle_iq(
|
||||
IQ :: iq(),
|
||||
Params :: gen_mod:opts()
|
||||
) ->
|
||||
Response :: iq().
|
||||
% Handle discovery requests. Produces a document such as depicted in
|
||||
% XEP-0363 v1.1.0 Ex. 4.
|
||||
handle_iq(#iq{type = get,
|
||||
lang = Lang,
|
||||
to = HostJID,
|
||||
sub_els = [#disco_info{}]} = IQ,
|
||||
#params{max_size = MaxSize, service_name = ServiceName}) ->
|
||||
Host = jid:encode(HostJID),
|
||||
% collect additional discovery entries, if any.
|
||||
Advice = ejabberd_hooks:run_fold(disco_info, Host, [],
|
||||
[Host, ?MODULE, <<"">>, Lang]),
|
||||
% if a maximum size was specified, append xdata with the limit
|
||||
XData = case MaxSize of
|
||||
infinity ->
|
||||
Advice;
|
||||
_ ->
|
||||
[#xdata{type = result,
|
||||
fields = http_upload:encode(
|
||||
[{'max-file-size', MaxSize}],
|
||||
?NS_HTTP_UPLOAD_0,
|
||||
Lang
|
||||
)}
|
||||
| Advice]
|
||||
end,
|
||||
% build disco iq
|
||||
Query = #disco_info{identities = [#identity{category = <<"store">>,
|
||||
type = <<"file">>,
|
||||
name = translate:translate(Lang, ServiceName)}],
|
||||
features = [?NS_HTTP_UPLOAD_0],
|
||||
xdata = XData},
|
||||
xmpp:make_iq_result(IQ, Query); % this swaps parties for us
|
||||
% handle slot request with FileSize > MaxSize
|
||||
handle_iq(#iq{type = get,
|
||||
from = From,
|
||||
lang = Lang,
|
||||
sub_els = [#upload_request_0{size = FileSize,
|
||||
filename = FileName}]} = IQ,
|
||||
#params{max_size = MaxSize}) when FileSize > MaxSize ->
|
||||
?WARNING_MSG("~ts tried to upload an oversize file (~ts, ~B bytes)",
|
||||
[jid:encode(From), FileName, FileSize]),
|
||||
ErrorMessage = {?T("File larger than ~B bytes"), [MaxSize]},
|
||||
Error = xmpp:err_not_acceptable(ErrorMessage, Lang),
|
||||
Els = [#upload_file_too_large{'max-file-size' = MaxSize,
|
||||
xmlns = ?NS_HTTP_UPLOAD_0}
|
||||
| xmpp:get_els(Error)],
|
||||
xmpp:make_error(IQ, xmpp:set_els(Error, Els));
|
||||
% Handle slot request
|
||||
handle_iq(#iq{type = get,
|
||||
from = Requester,
|
||||
lang = Lang,
|
||||
sub_els = [#upload_request_0{filename = FileName,
|
||||
size = FileSize} = UploadRequest]} = IQ,
|
||||
#params{server_host = ServerHost,
|
||||
access = Access,
|
||||
bucket_url = BucketURL,
|
||||
ttl = TTL,
|
||||
auth = Auth} = Params) ->
|
||||
case acl:match_rule(ServerHost, Access, Requester) of
|
||||
allow ->
|
||||
?INFO_MSG("Generating S3 Object URL Pair for ~ts to upload file ~ts (~B bytes)",
|
||||
[jid:encode(Requester), FileName, FileSize]),
|
||||
% generate a unique object ID and url based on settings
|
||||
ObjectURL = object_url(BucketURL, FileName),
|
||||
% attach configuration- and request-specific query params to the
|
||||
% PUT url
|
||||
UnsignedPutURL = put_url(UploadRequest, Params, ObjectURL),
|
||||
% sign the PUT url
|
||||
PutURL = aws_util:signed_url(Auth, put, ?AWS_SERVICE_S3, UnsignedPutURL, [], calendar:universal_time(), TTL),
|
||||
xmpp:make_iq_result(IQ, #upload_slot_0{get = ObjectURL,
|
||||
put = PutURL,
|
||||
xmlns = ?NS_HTTP_UPLOAD_0});
|
||||
deny ->
|
||||
?INFO_MSG("Denied upload request from ~ts for file ~ts (~B bytes)",
|
||||
[jid:encode(Requester), FileName, FileSize]),
|
||||
xmpp:make_error(IQ, xmpp:err_forbidden(?T("Access denied"), Lang))
|
||||
end;
|
||||
% handle unexpected IQ
|
||||
handle_iq(IQ, _Params) ->
|
||||
xmpp:make_error(IQ, xmpp:err_bad_request()).
|
||||
|
||||
%%------------------------------------------------------------------------
|
||||
%% Internal Helpers
|
||||
%%------------------------------------------------------------------------
|
||||
|
||||
-spec expanded_jids(
|
||||
ServiceHost :: binary(),
|
||||
JIDs :: [binary()]
|
||||
) ->
|
||||
ExpandedJIDs :: [binary()].
|
||||
% expand @HOST@ in JIDs
|
||||
expanded_jids(ServerHost, JIDs) ->
|
||||
lists:map(fun (JID) ->
|
||||
misc:expand_keyword(<<"@HOST@">>, JID, ServerHost)
|
||||
end, JIDs).
|
||||
|
||||
-spec build_service_params(
|
||||
ServerHost :: binary(),
|
||||
Opts :: gen_mod:opts()
|
||||
) ->
|
||||
Params :: #params{}.
|
||||
% create a service params record from module config
|
||||
build_service_params(ServerHost, Opts) ->
|
||||
Auth = #aws_auth{access_key_id = get_opt(access_key_id, Opts),
|
||||
access_key = get_opt(access_key_secret, Opts),
|
||||
region = get_opt(region, Opts)},
|
||||
#params{service_name = get_opt(service_name, Opts),
|
||||
service_jids = expanded_jids(ServerHost, get_opt(hosts, Opts)),
|
||||
max_size = get_opt(max_size, Opts),
|
||||
bucket_url = get_opt(bucket_url, Opts),
|
||||
set_public = get_opt(set_public, Opts),
|
||||
ttl = get_opt(put_ttl, Opts),
|
||||
server_host = ServerHost,
|
||||
auth = Auth,
|
||||
access = get_opt(access, Opts)}.
|
||||
|
||||
-spec url_service_parameters(
|
||||
Params :: #params{}
|
||||
) ->
|
||||
ServiceParameters :: [{binary(), binary() | true}].
|
||||
% additional URL parameters from module config
|
||||
url_service_parameters(#params{set_public = true}) ->
|
||||
[{<<"X-Amz-Acl">>, <<"public-read">>}];
|
||||
url_service_parameters(_) ->
|
||||
[].
|
||||
|
||||
-spec upload_parameters(
|
||||
UploadRequest :: #upload_request_0{},
|
||||
Params :: #params{}
|
||||
) ->
|
||||
UploadParameters :: [{binary(), binary() | true}].
|
||||
% headers to be included with the PUT request
|
||||
upload_parameters(#upload_request_0{size = FileSize,
|
||||
'content-type' = ContentType},
|
||||
ServiceParams) ->
|
||||
[{<<"Content-Type">>, <<ContentType/binary>>},
|
||||
{<<"Content-Length">>, erlang:integer_to_binary(FileSize)}
|
||||
| url_service_parameters(ServiceParams)].
|
||||
|
||||
-spec put_url(
|
||||
UploadRequest :: #upload_request_0{},
|
||||
Params :: #params{},
|
||||
URL :: binary()
|
||||
) ->
|
||||
PutURL :: binary().
|
||||
% attach additional query parameters (to the PUT URL), specifically canned ACL.
|
||||
put_url(UploadRequest, ServiceParams, URL) ->
|
||||
UriMap = uri_string:parse(URL),
|
||||
QueryList = case UriMap of
|
||||
#{query := QueryString} ->
|
||||
uri_string:dissect_query(QueryString);
|
||||
_ ->
|
||||
[]
|
||||
end,
|
||||
Params = upload_parameters(UploadRequest, ServiceParams),
|
||||
WithOpts = uri_string:compose_query(Params ++ QueryList),
|
||||
uri_string:recompose(UriMap#{query => WithOpts}).
|
||||
|
||||
-spec object_url(
|
||||
BucketURL :: binary(),
|
||||
FileName :: binary()
|
||||
) ->
|
||||
ObjectURL :: binary().
|
||||
% generate a unique random object URL for the given filename
|
||||
object_url(BucketURL, FileName) ->
|
||||
#{path := BasePath} = UriMap = uri_string:parse(BucketURL),
|
||||
ObjectName = object_name(FileName),
|
||||
uri_string:recompose(UriMap#{path => <<BasePath/binary, "/", ObjectName/binary>>}).
|
||||
|
||||
-spec object_name(
|
||||
FileName :: binary()
|
||||
) ->
|
||||
ObjectName :: binary().
|
||||
% generate a unique-in-time object name. the name consists of a hash
|
||||
% derived from the file, node, time, and a random number, a
|
||||
% forward-slash, and the original filename. this ensures that it does
|
||||
% not collide with other objects, while the forward slash ensures that
|
||||
% the client displays only the original file name.
|
||||
object_name(FileName) ->
|
||||
MD = crypto:hash_init(sha256),
|
||||
MDFilename = crypto:hash_update(MD, FileName),
|
||||
MDNodeName = crypto:hash_update(MDFilename, atom_to_list(node())),
|
||||
MDTime = crypto:hash_update(MDNodeName, <<(os:system_time())>>),
|
||||
MDRand = crypto:hash_update(MDTime, crypto:strong_rand_bytes(256)),
|
||||
Hash = crypto:hash_final(MDRand),
|
||||
<<(str:to_hexlist(Hash))/binary, "/", FileName/binary>>.
|
|
@ -0,0 +1,56 @@
|
|||
%%%----------------------------------------------------------------------
|
||||
%%% File : aws_util_SUITE.erl
|
||||
%%% Usage : AWS URL Signing Tests
|
||||
%%% Author : Roman Hargrave <roman@hargrave.info>
|
||||
%%% Created : 08 Sept 2022 by Roman Hargrave <roman@hargrave.info>
|
||||
%%%
|
||||
%%%
|
||||
%%% This program is free software; you can redistribute it and/or
|
||||
%%% modify it under the terms of the GNU General Public License as
|
||||
%%% published by the Free Software Foundation; either version 2 of the
|
||||
%%% License, or (at your option) any later version.
|
||||
%%%
|
||||
%%% This program is distributed in the hope that it will be useful,
|
||||
%%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
%%% General Public License for more details.
|
||||
%%%
|
||||
%%% You should have received a copy of the GNU General Public License along
|
||||
%%% with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
%%%----------------------------------------------------------------------
|
||||
|
||||
-module(aws_util_SUITE).
|
||||
-author("roman@hargrave.info").
|
||||
|
||||
-include("aws.hrl").
|
||||
|
||||
-behaviour(ct_suite).
|
||||
|
||||
-export([all/0,
|
||||
signs_url/1]).
|
||||
|
||||
all() ->
|
||||
[signs_url].
|
||||
|
||||
-define(EXPECT_SIGNED_URL,
|
||||
<<"https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host">>).
|
||||
|
||||
% Amazon effectively declares that any valid signing implementation
|
||||
% should generate the signature
|
||||
% aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404 for
|
||||
% the values given below
|
||||
signs_url(_Config) ->
|
||||
Auth = #aws_auth{access_key_id = <<"AKIAIOSFODNN7EXAMPLE">>,
|
||||
access_key = <<"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY">>,
|
||||
region = <<"us-east-1">>},
|
||||
Time = {{2013, 05, 24}, {00, 00, 00}},
|
||||
TTL = 86400,
|
||||
URL = <<"https://examplebucket.s3.amazonaws.com/test.txt">>,
|
||||
case aws_util:signed_url(Auth, get, ?AWS_SERVICE_S3, URL, [], Time, TTL) of
|
||||
?EXPECT_SIGNED_URL ->
|
||||
ok;
|
||||
UnexpectedUrl ->
|
||||
{failed, {{expected, ?EXPECT_SIGNED_URL},
|
||||
{received, UnexpectedUrl}}}
|
||||
end.
|
Loading…
Reference in New Issue