feat(upload): avatar and calendar cover via local disk storage.
POST /v1/user/me/avatar and /v1/calendars/:id/cover; GET /v1/media; UPLOAD_DIR on eventhub-data volume; MIME/size limits. Refs EventHub/EventHubBack#71 Refs EventHub/EventHubSpec#8
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc POST /v1/calendars/:id/cover — multipart cover upload (Back#71).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_calendar_cover).
|
||||
-behaviour(cowboy_handler).
|
||||
-export([init/2, trails/0]).
|
||||
-include("records.hrl").
|
||||
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"POST">> -> post_cover(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/calendars/:id/cover">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"Upload calendar cover image (owner; multipart field file)">>,
|
||||
tags => [<<"Calendars">>],
|
||||
requestBody => #{
|
||||
required => true,
|
||||
content => #{
|
||||
<<"multipart/form-data">> => #{
|
||||
schema => #{
|
||||
type => object,
|
||||
required => [<<"file">>],
|
||||
properties => #{file => #{type => string, format => binary}}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses => #{
|
||||
200 => #{description => <<"Updated calendar">>},
|
||||
401 => #{description => <<"Unauthorized">>},
|
||||
403 => #{description => <<"Forbidden">>},
|
||||
404 => #{description => <<"Not found">>},
|
||||
413 => #{description => <<"File too large">>},
|
||||
415 => #{description => <<"Unsupported media type">>},
|
||||
400 => #{description => <<"Missing file / bad multipart">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
post_cover(Req) ->
|
||||
CalendarId = cowboy_req:binding(id, Req),
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case core_calendar:get_by_id(CalendarId) of
|
||||
{ok, Cal} ->
|
||||
case logic_calendar:can_edit(UserId, Cal) of
|
||||
true ->
|
||||
do_upload(UserId, CalendarId, Cal, Req1);
|
||||
false ->
|
||||
handler_utils:send_error(Req1, 403, <<"Forbidden">>)
|
||||
end;
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Calendar not found">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
do_upload(UserId, CalendarId, #calendar{image_url = Old}, Req) ->
|
||||
case handler_upload_utils:read_image_part(Req) of
|
||||
{ok, Bin, Req2} ->
|
||||
case logic_upload:save_cover(CalendarId, UserId, Bin) of
|
||||
{ok, Url} ->
|
||||
case logic_calendar:update_calendar(UserId, CalendarId, [{<<"image_url">>, Url}]) of
|
||||
{ok, Updated} ->
|
||||
_ = logic_upload:maybe_delete_url(Old),
|
||||
handler_utils:send_json(Req2, 200, handler_utils:calendar_to_json(Updated));
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req2, 403, <<"Forbidden">>);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Update failed">>)
|
||||
end;
|
||||
{error, too_large} ->
|
||||
handler_utils:send_error(Req2, 413, <<"File too large">>);
|
||||
{error, unsupported_media} ->
|
||||
handler_utils:send_error(Req2, 415, <<"Unsupported media type">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Upload failed">>)
|
||||
end;
|
||||
{error, missing_file, Req2} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Missing file field">>);
|
||||
{error, too_large, Req2} ->
|
||||
handler_utils:send_error(Req2, 413, <<"File too large">>);
|
||||
{error, unsupported_media, Req2} ->
|
||||
handler_utils:send_error(Req2, 415, <<"Unsupported media type">>);
|
||||
{error, bad_multipart, Req2} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid multipart body">>)
|
||||
end.
|
||||
@@ -0,0 +1,69 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc GET /v1/media/:kind/:owner/:file — public static upload serve.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_media).
|
||||
-behaviour(cowboy_handler).
|
||||
-export([init/2, trails/0]).
|
||||
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> get_media(Req);
|
||||
<<"HEAD">> -> get_media(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/media/:kind/:owner/:file">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Serve uploaded avatar or cover image">>,
|
||||
tags => [<<"Media">>],
|
||||
responses => #{
|
||||
200 => #{description => <<"Image bytes">>},
|
||||
404 => #{description => <<"Not found">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
get_media(Req) ->
|
||||
KindB = cowboy_req:binding(kind, Req),
|
||||
Owner = cowboy_req:binding(owner, Req),
|
||||
File = cowboy_req:binding(file, Req),
|
||||
Kind = case KindB of
|
||||
<<"avatar">> -> avatar;
|
||||
<<"cover">> -> cover;
|
||||
_ -> invalid
|
||||
end,
|
||||
case Kind of
|
||||
invalid ->
|
||||
handler_utils:send_error(Req, 404, <<"Not found">>);
|
||||
_ ->
|
||||
case logic_upload:media_abs_path(Kind, Owner, File) of
|
||||
{ok, Path} ->
|
||||
Mime = mime_for(File),
|
||||
case file:read_file(Path) of
|
||||
{ok, Bin} ->
|
||||
Headers = #{
|
||||
<<"content-type">> => Mime,
|
||||
<<"cache-control">> => <<"public, max-age=86400">>
|
||||
},
|
||||
Req1 = cowboy_req:reply(200, Headers, Bin, Req),
|
||||
{ok, Bin, Req1};
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req, 404, <<"Not found">>)
|
||||
end;
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req, 404, <<"Not found">>)
|
||||
end
|
||||
end.
|
||||
|
||||
mime_for(File) when is_binary(File) ->
|
||||
case filename:extension(binary_to_list(File)) of
|
||||
".png" -> <<"image/png">>;
|
||||
".webp" -> <<"image/webp">>;
|
||||
_ -> <<"image/jpeg">>
|
||||
end.
|
||||
@@ -0,0 +1,71 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Multipart helpers for image uploads (Back#71).
|
||||
%%% Field name: file
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_upload_utils).
|
||||
-export([read_image_part/1]).
|
||||
|
||||
-spec read_image_part(cowboy_req:req()) ->
|
||||
{ok, binary(), cowboy_req:req()} |
|
||||
{error, missing_file | too_large | unsupported_media | bad_multipart, cowboy_req:req()}.
|
||||
read_image_part(Req) ->
|
||||
Max = logic_upload:max_bytes(),
|
||||
try read_parts(Req, Max)
|
||||
catch
|
||||
_:_ -> {error, bad_multipart, Req}
|
||||
end.
|
||||
|
||||
read_parts(Req, Max) ->
|
||||
case cowboy_req:read_part(Req) of
|
||||
{ok, Headers, Req1} ->
|
||||
case cow_multipart:form_data(Headers) of
|
||||
{file, _Name, _Filename, _CType} ->
|
||||
case read_limited(Req1, Max, <<>>) of
|
||||
{ok, Bin, Req2} ->
|
||||
case logic_upload:detect_image(Bin) of
|
||||
{ok, _, _} -> {ok, Bin, Req2};
|
||||
{error, unsupported_media} ->
|
||||
{error, unsupported_media, Req2}
|
||||
end;
|
||||
{error, too_large, Req2} ->
|
||||
{error, too_large, Req2}
|
||||
end;
|
||||
{data, _Name} ->
|
||||
{ok, _Body, Req2} = cowboy_req:read_part_body(Req1),
|
||||
read_parts(Req2, Max)
|
||||
end;
|
||||
{done, Req1} ->
|
||||
{error, missing_file, Req1}
|
||||
end.
|
||||
|
||||
read_limited(Req, Max, Acc) when byte_size(Acc) > Max ->
|
||||
_ = drain(Req),
|
||||
{error, too_large, Req};
|
||||
read_limited(Req, Max, Acc) ->
|
||||
Chunk = min(65536, Max - byte_size(Acc) + 1),
|
||||
case cowboy_req:read_part_body(Req, #{length => Chunk}) of
|
||||
{ok, Data, Req2} ->
|
||||
Bin = <<Acc/binary, Data/binary>>,
|
||||
case byte_size(Bin) > Max of
|
||||
true -> {error, too_large, Req2};
|
||||
false -> {ok, Bin, Req2}
|
||||
end;
|
||||
{more, Data, Req2} ->
|
||||
Bin = <<Acc/binary, Data/binary>>,
|
||||
case byte_size(Bin) > Max of
|
||||
true ->
|
||||
_ = drain(Req2),
|
||||
{error, too_large, Req2};
|
||||
false ->
|
||||
read_limited(Req2, Max, Bin)
|
||||
end
|
||||
end.
|
||||
|
||||
drain(Req) ->
|
||||
case cowboy_req:read_part_body(Req, #{length => 65536}) of
|
||||
{ok, _, Req2} -> Req2;
|
||||
{more, _, Req2} -> drain(Req2);
|
||||
{done, Req2} -> Req2;
|
||||
_ -> Req
|
||||
end.
|
||||
@@ -0,0 +1,85 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc POST /v1/user/me/avatar — multipart image upload (Back#71).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_user_avatar).
|
||||
-behaviour(cowboy_handler).
|
||||
-export([init/2, trails/0]).
|
||||
-include("records.hrl").
|
||||
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"POST">> -> post_avatar(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/user/me/avatar">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"Upload current user avatar (multipart field file)">>,
|
||||
tags => [<<"Users">>],
|
||||
requestBody => #{
|
||||
required => true,
|
||||
content => #{
|
||||
<<"multipart/form-data">> => #{
|
||||
schema => #{
|
||||
type => object,
|
||||
required => [<<"file">>],
|
||||
properties => #{file => #{type => string, format => binary}}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses => #{
|
||||
200 => #{description => <<"Updated user profile">>},
|
||||
401 => #{description => <<"Unauthorized">>},
|
||||
413 => #{description => <<"File too large">>},
|
||||
415 => #{description => <<"Unsupported media type">>},
|
||||
400 => #{description => <<"Missing file / bad multipart">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
post_avatar(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case handler_upload_utils:read_image_part(Req1) of
|
||||
{ok, Bin, Req2} ->
|
||||
case logic_upload:save_avatar(UserId, Bin) of
|
||||
{ok, Url} ->
|
||||
Old = case core_user:get_by_id(UserId) of
|
||||
{ok, #user{avatar_url = A}} -> A;
|
||||
_ -> undefined
|
||||
end,
|
||||
case core_user:update(UserId, [{avatar_url, Url}]) of
|
||||
{ok, User} ->
|
||||
_ = logic_upload:maybe_delete_url(Old),
|
||||
handler_utils:send_json(Req2, 200, handler_utils:user_to_json(User));
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req2, 404, <<"User not found">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Update failed">>)
|
||||
end;
|
||||
{error, too_large} ->
|
||||
handler_utils:send_error(Req2, 413, <<"File too large">>);
|
||||
{error, unsupported_media} ->
|
||||
handler_utils:send_error(Req2, 415, <<"Unsupported media type">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Upload failed">>)
|
||||
end;
|
||||
{error, missing_file, Req2} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Missing file field">>);
|
||||
{error, too_large, Req2} ->
|
||||
handler_utils:send_error(Req2, 413, <<"File too large">>);
|
||||
{error, unsupported_media, Req2} ->
|
||||
handler_utils:send_error(Req2, 415, <<"Unsupported media type">>);
|
||||
{error, bad_multipart, Req2} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid multipart body">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
Reference in New Issue
Block a user