diff --git a/docker/.env.example b/docker/.env.example index 113fcb9..eb07b9c 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -30,4 +30,7 @@ SMTP_TLS=if_available PUBLIC_APP_URL=https://stage.calentiq.com # Окно напоминаний о booking (часы до старта; Back#70) REMINDER_LEAD_HOURS=24 +# Uploads (avatar/cover) — каталог на volume /app/data (Back#71) +UPLOAD_DIR=/app/data/uploads +UPLOAD_MAX_BYTES=2097152 diff --git a/docker/docker-compose.stage.yml b/docker/docker-compose.stage.yml index 27a64fc..dcbb916 100644 --- a/docker/docker-compose.stage.yml +++ b/docker/docker-compose.stage.yml @@ -49,6 +49,8 @@ services: - SMTP_TLS=${SMTP_TLS:-if_available} - PUBLIC_APP_URL=${PUBLIC_APP_URL:-https://stage.calentiq.com} - REMINDER_LEAD_HOURS=${REMINDER_LEAD_HOURS:-24} + - UPLOAD_DIR=${UPLOAD_DIR:-/app/data/uploads} + - UPLOAD_MAX_BYTES=${UPLOAD_MAX_BYTES:-2097152} networks: eventhub-net: aliases: diff --git a/docker/erlang/Dockerfile.runtime b/docker/erlang/Dockerfile.runtime index 445954d..1be0cb1 100644 --- a/docker/erlang/Dockerfile.runtime +++ b/docker/erlang/Dockerfile.runtime @@ -7,7 +7,7 @@ FROM alpine:${ALPINE_VERSION} RUN apk add --no-cache \ openssl libstdc++ libgcc ncurses-libs libsodium \ file \ - && mkdir -p /app/data \ - && chmod 777 /app/data + && mkdir -p /app/data /app/data/uploads \ + && chmod 777 /app/data /app/data/uploads WORKDIR /app diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index 2688f6f..d32472d 100755 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -83,6 +83,7 @@ start_http() -> {'_', [ {"/metrics/[:registry]", prometheus_cowboy2_handler, []}, {"/health", handler_health, []}, + {"/v1/media/:kind/:owner/:file", handler_media, []}, {"/v1/register", handler_register, []}, {"/v1/verify", handler_verify, []}, {"/v1/forgot-password", handler_forgot_password, []}, @@ -91,6 +92,7 @@ start_http() -> {"/v1/refresh", handler_refresh, []}, {"/v1/logout", handler_logout, []}, {"/v1/user/me", handler_user_me, []}, + {"/v1/user/me/avatar", handler_user_avatar, []}, {"/v1/user/bookings", handler_user_bookings, []}, {"/v1/user/booking-requests", handler_user_booking_requests, []}, {"/v1/user/studio-bookings", handler_user_studio_bookings, []}, @@ -101,6 +103,7 @@ start_http() -> {"/v1/search", handler_search, []}, {"/v1/calendars", handler_calendars, []}, {"/v1/calendars/:id", handler_calendar_by_id, []}, + {"/v1/calendars/:id/cover", handler_calendar_cover, []}, {"/v1/calendars/:id/follow", handler_calendar_follow, []}, {"/v1/calendars/:id/specialists", handler_calendar_specialists, []}, {"/v1/calendars/:id/specialists/:user_id", handler_calendar_specialists, []}, diff --git a/src/handlers/handler_calendar_cover.erl b/src/handlers/handler_calendar_cover.erl new file mode 100644 index 0000000..f6b61ed --- /dev/null +++ b/src/handlers/handler_calendar_cover.erl @@ -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. diff --git a/src/handlers/handler_media.erl b/src/handlers/handler_media.erl new file mode 100644 index 0000000..17dc701 --- /dev/null +++ b/src/handlers/handler_media.erl @@ -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. diff --git a/src/handlers/handler_upload_utils.erl b/src/handlers/handler_upload_utils.erl new file mode 100644 index 0000000..cbb5013 --- /dev/null +++ b/src/handlers/handler_upload_utils.erl @@ -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 = <>, + case byte_size(Bin) > Max of + true -> {error, too_large, Req2}; + false -> {ok, Bin, Req2} + end; + {more, Data, Req2} -> + Bin = <>, + 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. diff --git a/src/handlers/handler_user_avatar.erl b/src/handlers/handler_user_avatar.erl new file mode 100644 index 0000000..870fa20 --- /dev/null +++ b/src/handlers/handler_user_avatar.erl @@ -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. diff --git a/src/logic/logic_upload.erl b/src/logic/logic_upload.erl new file mode 100644 index 0000000..4261def --- /dev/null +++ b/src/logic/logic_upload.erl @@ -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 = <>, + 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. diff --git a/src/swagger/eventhub_trails.erl b/src/swagger/eventhub_trails.erl index 15eb834..37bc78e 100755 --- a/src/swagger/eventhub_trails.erl +++ b/src/swagger/eventhub_trails.erl @@ -100,6 +100,9 @@ user() -> handler_user_studio_bookings, handler_user_following, handler_user_me, + handler_user_avatar, + handler_calendar_cover, + handler_media, handler_user_reviews ], lists:flatmap(fun trails_from_module/1, Modules). diff --git a/test/unit/logic_upload_tests.erl b/test/unit/logic_upload_tests.erl new file mode 100644 index 0000000..0327fc5 --- /dev/null +++ b/test/unit/logic_upload_tests.erl @@ -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)).