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:
@@ -30,4 +30,7 @@ SMTP_TLS=if_available
|
|||||||
PUBLIC_APP_URL=https://stage.calentiq.com
|
PUBLIC_APP_URL=https://stage.calentiq.com
|
||||||
# Окно напоминаний о booking (часы до старта; Back#70)
|
# Окно напоминаний о booking (часы до старта; Back#70)
|
||||||
REMINDER_LEAD_HOURS=24
|
REMINDER_LEAD_HOURS=24
|
||||||
|
# Uploads (avatar/cover) — каталог на volume /app/data (Back#71)
|
||||||
|
UPLOAD_DIR=/app/data/uploads
|
||||||
|
UPLOAD_MAX_BYTES=2097152
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ services:
|
|||||||
- SMTP_TLS=${SMTP_TLS:-if_available}
|
- SMTP_TLS=${SMTP_TLS:-if_available}
|
||||||
- PUBLIC_APP_URL=${PUBLIC_APP_URL:-https://stage.calentiq.com}
|
- PUBLIC_APP_URL=${PUBLIC_APP_URL:-https://stage.calentiq.com}
|
||||||
- REMINDER_LEAD_HOURS=${REMINDER_LEAD_HOURS:-24}
|
- REMINDER_LEAD_HOURS=${REMINDER_LEAD_HOURS:-24}
|
||||||
|
- UPLOAD_DIR=${UPLOAD_DIR:-/app/data/uploads}
|
||||||
|
- UPLOAD_MAX_BYTES=${UPLOAD_MAX_BYTES:-2097152}
|
||||||
networks:
|
networks:
|
||||||
eventhub-net:
|
eventhub-net:
|
||||||
aliases:
|
aliases:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ FROM alpine:${ALPINE_VERSION}
|
|||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
openssl libstdc++ libgcc ncurses-libs libsodium \
|
openssl libstdc++ libgcc ncurses-libs libsodium \
|
||||||
file \
|
file \
|
||||||
&& mkdir -p /app/data \
|
&& mkdir -p /app/data /app/data/uploads \
|
||||||
&& chmod 777 /app/data
|
&& chmod 777 /app/data /app/data/uploads
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ start_http() ->
|
|||||||
{'_', [
|
{'_', [
|
||||||
{"/metrics/[:registry]", prometheus_cowboy2_handler, []},
|
{"/metrics/[:registry]", prometheus_cowboy2_handler, []},
|
||||||
{"/health", handler_health, []},
|
{"/health", handler_health, []},
|
||||||
|
{"/v1/media/:kind/:owner/:file", handler_media, []},
|
||||||
{"/v1/register", handler_register, []},
|
{"/v1/register", handler_register, []},
|
||||||
{"/v1/verify", handler_verify, []},
|
{"/v1/verify", handler_verify, []},
|
||||||
{"/v1/forgot-password", handler_forgot_password, []},
|
{"/v1/forgot-password", handler_forgot_password, []},
|
||||||
@@ -91,6 +92,7 @@ start_http() ->
|
|||||||
{"/v1/refresh", handler_refresh, []},
|
{"/v1/refresh", handler_refresh, []},
|
||||||
{"/v1/logout", handler_logout, []},
|
{"/v1/logout", handler_logout, []},
|
||||||
{"/v1/user/me", handler_user_me, []},
|
{"/v1/user/me", handler_user_me, []},
|
||||||
|
{"/v1/user/me/avatar", handler_user_avatar, []},
|
||||||
{"/v1/user/bookings", handler_user_bookings, []},
|
{"/v1/user/bookings", handler_user_bookings, []},
|
||||||
{"/v1/user/booking-requests", handler_user_booking_requests, []},
|
{"/v1/user/booking-requests", handler_user_booking_requests, []},
|
||||||
{"/v1/user/studio-bookings", handler_user_studio_bookings, []},
|
{"/v1/user/studio-bookings", handler_user_studio_bookings, []},
|
||||||
@@ -101,6 +103,7 @@ start_http() ->
|
|||||||
{"/v1/search", handler_search, []},
|
{"/v1/search", handler_search, []},
|
||||||
{"/v1/calendars", handler_calendars, []},
|
{"/v1/calendars", handler_calendars, []},
|
||||||
{"/v1/calendars/:id", handler_calendar_by_id, []},
|
{"/v1/calendars/:id", handler_calendar_by_id, []},
|
||||||
|
{"/v1/calendars/:id/cover", handler_calendar_cover, []},
|
||||||
{"/v1/calendars/:id/follow", handler_calendar_follow, []},
|
{"/v1/calendars/:id/follow", handler_calendar_follow, []},
|
||||||
{"/v1/calendars/:id/specialists", handler_calendar_specialists, []},
|
{"/v1/calendars/:id/specialists", handler_calendar_specialists, []},
|
||||||
{"/v1/calendars/:id/specialists/:user_id", handler_calendar_specialists, []},
|
{"/v1/calendars/:id/specialists/:user_id", handler_calendar_specialists, []},
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Local-disk uploads for avatar/cover (Back#71, variant A).
|
||||||
|
%%% Env: UPLOAD_DIR (default /app/data/uploads), UPLOAD_MAX_BYTES (default 2MiB).
|
||||||
|
%%% Public URLs: /v1/media/{avatar|cover}/:owner/:file
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(logic_upload).
|
||||||
|
-export([max_bytes/0, upload_dir/0, ensure_dir/0,
|
||||||
|
save_avatar/2, save_cover/3,
|
||||||
|
media_abs_path/3, detect_image/1, maybe_delete_url/1]).
|
||||||
|
|
||||||
|
-define(DEFAULT_MAX, 2097152). %% 2 MiB
|
||||||
|
-define(DEFAULT_DIR, "/app/data/uploads").
|
||||||
|
|
||||||
|
-spec max_bytes() -> pos_integer().
|
||||||
|
max_bytes() ->
|
||||||
|
case application:get_env(eventhub, upload_max_bytes) of
|
||||||
|
{ok, N} when is_integer(N), N > 0 -> N;
|
||||||
|
_ ->
|
||||||
|
case os:getenv("UPLOAD_MAX_BYTES") of
|
||||||
|
false -> ?DEFAULT_MAX;
|
||||||
|
"" -> ?DEFAULT_MAX;
|
||||||
|
S ->
|
||||||
|
try list_to_integer(S) of
|
||||||
|
N when N > 0 -> N;
|
||||||
|
_ -> ?DEFAULT_MAX
|
||||||
|
catch
|
||||||
|
_:_ -> ?DEFAULT_MAX
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec upload_dir() -> string().
|
||||||
|
upload_dir() ->
|
||||||
|
case application:get_env(eventhub, upload_dir) of
|
||||||
|
{ok, Dir} when is_list(Dir), Dir =/= "" -> Dir;
|
||||||
|
{ok, Dir} when is_binary(Dir), Dir =/= <<>> -> binary_to_list(Dir);
|
||||||
|
_ ->
|
||||||
|
case os:getenv("UPLOAD_DIR") of
|
||||||
|
false -> ?DEFAULT_DIR;
|
||||||
|
"" -> ?DEFAULT_DIR;
|
||||||
|
S -> S
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec ensure_dir() -> ok | {error, term()}.
|
||||||
|
ensure_dir() ->
|
||||||
|
Dir = upload_dir(),
|
||||||
|
case filelib:is_dir(Dir) of
|
||||||
|
true -> ok;
|
||||||
|
false -> filelib:ensure_dir(filename:join(Dir, "dummy"))
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% @doc Save avatar bytes; returns public URL path.
|
||||||
|
-spec save_avatar(UserId :: binary(), Bin :: binary()) ->
|
||||||
|
{ok, Url :: binary()} | {error, too_large | unsupported_media | term()}.
|
||||||
|
save_avatar(UserId, Bin) when is_binary(UserId), is_binary(Bin) ->
|
||||||
|
save(avatar, UserId, Bin).
|
||||||
|
|
||||||
|
%% @doc Save calendar cover; returns public URL path.
|
||||||
|
-spec save_cover(CalendarId :: binary(), OwnerUserId :: binary(), Bin :: binary()) ->
|
||||||
|
{ok, Url :: binary()} | {error, too_large | unsupported_media | term()}.
|
||||||
|
save_cover(CalendarId, _OwnerUserId, Bin)
|
||||||
|
when is_binary(CalendarId), is_binary(Bin) ->
|
||||||
|
save(cover, CalendarId, Bin).
|
||||||
|
|
||||||
|
-spec media_abs_path(Kind :: avatar | cover, OwnerId :: binary(), File :: binary()) ->
|
||||||
|
{ok, file:filename_all()} | {error, not_found | invalid}.
|
||||||
|
media_abs_path(Kind, OwnerId, File)
|
||||||
|
when (Kind =:= avatar orelse Kind =:= cover),
|
||||||
|
is_binary(OwnerId), is_binary(File) ->
|
||||||
|
case safe_segment(OwnerId) andalso safe_filename(File) of
|
||||||
|
true ->
|
||||||
|
KindS = atom_to_list(Kind),
|
||||||
|
Path = filename:join([upload_dir(), KindS,
|
||||||
|
binary_to_list(OwnerId), binary_to_list(File)]),
|
||||||
|
case filelib:is_regular(Path) of
|
||||||
|
true -> {ok, Path};
|
||||||
|
false -> {error, not_found}
|
||||||
|
end;
|
||||||
|
false ->
|
||||||
|
{error, invalid}
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec detect_image(binary()) ->
|
||||||
|
{ok, Ext :: binary(), Mime :: binary()} | {error, unsupported_media}.
|
||||||
|
detect_image(<<16#FF, 16#D8, 16#FF, _/binary>>) ->
|
||||||
|
{ok, <<"jpg">>, <<"image/jpeg">>};
|
||||||
|
detect_image(<<137, 80, 78, 71, 13, 10, 26, 10, _/binary>>) ->
|
||||||
|
{ok, <<"png">>, <<"image/png">>};
|
||||||
|
detect_image(<<"RIFF", _:4/binary, "WEBP", _/binary>>) ->
|
||||||
|
{ok, <<"webp">>, <<"image/webp">>};
|
||||||
|
detect_image(_) ->
|
||||||
|
{error, unsupported_media}.
|
||||||
|
|
||||||
|
-spec maybe_delete_url(binary() | default | undefined) -> ok.
|
||||||
|
maybe_delete_url(Url) when is_binary(Url) ->
|
||||||
|
case Url of
|
||||||
|
<<"/v1/media/", Rest/binary>> ->
|
||||||
|
case binary:split(Rest, <<"/">>, [global]) of
|
||||||
|
[KindB, OwnerId, File] ->
|
||||||
|
Kind = case KindB of
|
||||||
|
<<"avatar">> -> avatar;
|
||||||
|
<<"cover">> -> cover;
|
||||||
|
_ -> undefined
|
||||||
|
end,
|
||||||
|
case Kind of
|
||||||
|
undefined -> ok;
|
||||||
|
_ ->
|
||||||
|
case media_abs_path(Kind, OwnerId, File) of
|
||||||
|
{ok, Path} -> _ = file:delete(Path), ok;
|
||||||
|
_ -> ok
|
||||||
|
end
|
||||||
|
end;
|
||||||
|
_ -> ok
|
||||||
|
end;
|
||||||
|
_ -> ok
|
||||||
|
end;
|
||||||
|
maybe_delete_url(_) ->
|
||||||
|
ok.
|
||||||
|
|
||||||
|
%%%===================================================================
|
||||||
|
%%% INTERNAL
|
||||||
|
%%%===================================================================
|
||||||
|
|
||||||
|
save(Kind, OwnerId, Bin) ->
|
||||||
|
case byte_size(Bin) > max_bytes() of
|
||||||
|
true ->
|
||||||
|
{error, too_large};
|
||||||
|
false ->
|
||||||
|
case detect_image(Bin) of
|
||||||
|
{ok, Ext, _Mime} ->
|
||||||
|
case safe_segment(OwnerId) of
|
||||||
|
false ->
|
||||||
|
{error, invalid};
|
||||||
|
true ->
|
||||||
|
write_new(Kind, OwnerId, Ext, Bin)
|
||||||
|
end;
|
||||||
|
{error, _} = E ->
|
||||||
|
E
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
write_new(Kind, OwnerId, Ext, Bin) ->
|
||||||
|
_ = ensure_dir(),
|
||||||
|
FileId = infra_utils:generate_id(16),
|
||||||
|
File = <<FileId/binary, ".", Ext/binary>>,
|
||||||
|
KindS = atom_to_list(Kind),
|
||||||
|
RelDir = filename:join([upload_dir(), KindS, binary_to_list(OwnerId)]),
|
||||||
|
AbsFile = filename:join(RelDir, binary_to_list(File)),
|
||||||
|
ok = filelib:ensure_dir(AbsFile),
|
||||||
|
case file:write_file(AbsFile, Bin) of
|
||||||
|
ok ->
|
||||||
|
Url = iolist_to_binary([<<"/v1/media/">>, KindS, <<"/">>,
|
||||||
|
OwnerId, <<"/">>, File]),
|
||||||
|
{ok, Url};
|
||||||
|
{error, Reason} ->
|
||||||
|
{error, Reason}
|
||||||
|
end.
|
||||||
|
|
||||||
|
safe_segment(Bin) when is_binary(Bin), byte_size(Bin) > 0, byte_size(Bin) < 128 ->
|
||||||
|
case binary:match(Bin, [<<"/">>, <<"..">>, <<"\\">>]) of
|
||||||
|
nomatch -> true;
|
||||||
|
_ -> false
|
||||||
|
end;
|
||||||
|
safe_segment(_) ->
|
||||||
|
false.
|
||||||
|
|
||||||
|
safe_filename(Bin) when is_binary(Bin), byte_size(Bin) > 0, byte_size(Bin) < 128 ->
|
||||||
|
case re:run(Bin, <<"^[A-Za-z0-9_-]+\\.(jpg|png|webp)$">>, [{capture, none}]) of
|
||||||
|
match -> true;
|
||||||
|
nomatch -> false
|
||||||
|
end;
|
||||||
|
safe_filename(_) ->
|
||||||
|
false.
|
||||||
@@ -100,6 +100,9 @@ user() ->
|
|||||||
handler_user_studio_bookings,
|
handler_user_studio_bookings,
|
||||||
handler_user_following,
|
handler_user_following,
|
||||||
handler_user_me,
|
handler_user_me,
|
||||||
|
handler_user_avatar,
|
||||||
|
handler_calendar_cover,
|
||||||
|
handler_media,
|
||||||
handler_user_reviews
|
handler_user_reviews
|
||||||
],
|
],
|
||||||
lists:flatmap(fun trails_from_module/1, Modules).
|
lists:flatmap(fun trails_from_module/1, Modules).
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
-module(logic_upload_tests).
|
||||||
|
-include_lib("eunit/include/eunit.hrl").
|
||||||
|
|
||||||
|
logic_upload_test_() ->
|
||||||
|
{foreach, fun setup/0, fun cleanup/1, [
|
||||||
|
{"png avatar saves and serves path", fun test_png_avatar/0},
|
||||||
|
{"jpeg ok", fun test_jpeg/0},
|
||||||
|
{"webp ok", fun test_webp/0},
|
||||||
|
{"too large", fun test_too_large/0},
|
||||||
|
{"unsupported media", fun test_bad_media/0},
|
||||||
|
{"path traversal rejected", fun test_bad_path/0},
|
||||||
|
{"cover + delete old url", fun test_cover_and_delete/0}
|
||||||
|
]}.
|
||||||
|
|
||||||
|
setup() ->
|
||||||
|
Dir = "/tmp/eh-upload-test-" ++ integer_to_list(erlang:unique_integer([positive])),
|
||||||
|
ok = filelib:ensure_dir(filename:join(Dir, "dummy")),
|
||||||
|
application:set_env(eventhub, upload_dir, Dir),
|
||||||
|
application:set_env(eventhub, upload_max_bytes, 1024),
|
||||||
|
Dir.
|
||||||
|
|
||||||
|
cleanup(Dir) ->
|
||||||
|
application:unset_env(eventhub, upload_dir),
|
||||||
|
application:unset_env(eventhub, upload_max_bytes),
|
||||||
|
_ = os:cmd("rm -rf " ++ Dir),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
png() ->
|
||||||
|
<<137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 0>>.
|
||||||
|
|
||||||
|
jpeg() ->
|
||||||
|
<<16#FF, 16#D8, 16#FF, 16#E0, 0, 0>>.
|
||||||
|
|
||||||
|
webp() ->
|
||||||
|
<<"RIFF", 0, 0, 0, 0, "WEBP", 0, 0>>.
|
||||||
|
|
||||||
|
test_png_avatar() ->
|
||||||
|
UserId = <<"user1">>,
|
||||||
|
{ok, Url} = logic_upload:save_avatar(UserId, png()),
|
||||||
|
?assertMatch(<<"/v1/media/avatar/user1/", _/binary>>, Url),
|
||||||
|
<<"/v1/media/", Rest/binary>> = Url,
|
||||||
|
[<<"avatar">>, <<"user1">>, File] = binary:split(Rest, <<"/">>, [global]),
|
||||||
|
{ok, Path} = logic_upload:media_abs_path(avatar, UserId, File),
|
||||||
|
{ok, Bin} = file:read_file(Path),
|
||||||
|
?assertEqual(png(), Bin).
|
||||||
|
|
||||||
|
test_jpeg() ->
|
||||||
|
{ok, Url} = logic_upload:save_avatar(<<"u">>, jpeg()),
|
||||||
|
?assert(binary:match(Url, <<".jpg">>) =/= nomatch).
|
||||||
|
|
||||||
|
test_webp() ->
|
||||||
|
{ok, Url} = logic_upload:save_cover(<<"cal1">>, <<"owner">>, webp()),
|
||||||
|
?assert(binary:match(Url, <<".webp">>) =/= nomatch),
|
||||||
|
?assertMatch(<<"/v1/media/cover/cal1/", _/binary>>, Url).
|
||||||
|
|
||||||
|
test_too_large() ->
|
||||||
|
Big = list_to_binary(lists:duplicate(2000, $x)),
|
||||||
|
%% not a valid image either, but size checked first
|
||||||
|
?assertEqual({error, too_large}, logic_upload:save_avatar(<<"u">>, Big)).
|
||||||
|
|
||||||
|
test_bad_media() ->
|
||||||
|
?assertEqual({error, unsupported_media},
|
||||||
|
logic_upload:save_avatar(<<"u">>, <<"not-an-image">>)).
|
||||||
|
|
||||||
|
test_bad_path() ->
|
||||||
|
?assertEqual({error, invalid},
|
||||||
|
logic_upload:media_abs_path(avatar, <<"../x">>, <<"a.jpg">>)),
|
||||||
|
?assertEqual({error, invalid},
|
||||||
|
logic_upload:media_abs_path(avatar, <<"u">>, <<"../a.jpg">>)).
|
||||||
|
|
||||||
|
test_cover_and_delete() ->
|
||||||
|
{ok, Url1} = logic_upload:save_cover(<<"c">>, <<"o">>, png()),
|
||||||
|
<<"/v1/media/", Rest/binary>> = Url1,
|
||||||
|
[<<"cover">>, <<"c">>, File] = binary:split(Rest, <<"/">>, [global]),
|
||||||
|
{ok, Path} = logic_upload:media_abs_path(cover, <<"c">>, File),
|
||||||
|
?assert(filelib:is_regular(Path)),
|
||||||
|
ok = logic_upload:maybe_delete_url(Url1),
|
||||||
|
?assertNot(filelib:is_regular(Path)).
|
||||||
Reference in New Issue
Block a user