-module(jchat_http). -export([start_link/0, init/2]). start_link() -> Port = jchat_config:http_port(), ApiDomain = jchat_config:api_domain(), WebDomain = jchat_config:web_domain(), Dispatch = cowboy_router:compile([ % API domain routing - NO static files {ApiDomain, [ {"/jmap/api", ?MODULE, []}, {"/jmap/upload/[...]", jchat_http_upload, []}, {"/jmap/download/[...]", jchat_http_download, []}, {"/jmap/eventsource", jchat_http_eventsource, []}, {"/auth/[...]", jchat_http_auth, []}, {"/_health", jchat_http_health, []}, {"/[...]", jchat_http_404, []} % 404 for unknown API requests ]}, % Web domain routing - ONLY static files {WebDomain, [ {"/[...]", jchat_http_static, []} ]}, % Fallback for any other domain - redirect to web domain {'_', [ {"/_health", jchat_http_health, []}, {"/[...]", jchat_http_redirect, [{web_domain, WebDomain}]} ]} ]), {ok, _} = cowboy:start_clear(http, [{port, Port}], #{ env => #{dispatch => Dispatch} }), {ok, self()}. init(Req0, State) -> Method = cowboy_req:method(Req0), handle_request(Method, Req0, State). handle_request(<<"POST">>, Req0, State) -> case cowboy_req:read_body(Req0) of {ok, Body, Req1} -> process_jmap_request(Body, Req1, State); {more, _Data, Req1} -> % Handle large requests {ok, cowboy_req:reply(413, #{}, <<"Request too large">>, Req1), State} end; handle_request(<<"OPTIONS">>, Req0, State) -> % CORS preflight Req1 = cowboy_req:reply(200, #{ <<"access-control-allow-origin">> => <<"*">>, <<"access-control-allow-methods">> => <<"POST, OPTIONS">>, <<"access-control-allow-headers">> => <<"content-type, authorization">> }, <<>>, Req0), {ok, Req1, State}; handle_request(_Method, Req0, State) -> Req1 = cowboy_req:reply(405, #{}, <<"Method Not Allowed">>, Req0), {ok, Req1, State}. process_jmap_request(Body, Req0, State) -> % Extract Authorization header first AuthHeader = cowboy_req:header(<<"authorization">>, Req0), case jchat_utils:json_decode(Body) of {ok, RequestData} -> % Validate Content-Type as per JMAP spec case validate_content_type(Req0) of ok -> case process_jmap_data(RequestData, AuthHeader) of {ok, ResponseData} -> JSON = jchat_utils:json_encode(ResponseData), Req1 = cowboy_req:reply(200, #{ <<"content-type">> => <<"application/json; charset=utf-8">>, <<"access-control-allow-origin">> => <<"*">> }, JSON, Req0), {ok, Req1, State}; {error, Error} -> ErrorResponse = jchat_utils:format_error(Error), JSON = jchat_utils:json_encode(#{ <<"type">> => maps:get(type, ErrorResponse), <<"status">> => maps:get(status, ErrorResponse, 400), <<"detail">> => maps:get(description, ErrorResponse, <<"Unknown error">>) }), Req1 = cowboy_req:reply(400, #{ <<"content-type">> => <<"application/json; charset=utf-8">>, <<"access-control-allow-origin">> => <<"*">> }, JSON, Req0), {ok, Req1, State} end; {error, invalid_content_type} -> ErrorJSON = jchat_utils:json_encode(#{ type => <<"urn:ietf:params:jmap:error:notJSON">>, status => 400, detail => <<"Content-Type must be application/json">> }), Req1 = cowboy_req:reply(400, #{ <<"content-type">> => <<"application/json; charset=utf-8">>, <<"access-control-allow-origin">> => <<"*">> }, ErrorJSON, Req0), {ok, Req1, State} end; {error, invalid_json} -> ErrorJSON = jchat_utils:json_encode(#{ type => <<"urn:ietf:params:jmap:error:notJSON">>, status => 400, detail => <<"Request is not valid JSON">> }), Req1 = cowboy_req:reply(400, #{ <<"content-type">> => <<"application/json; charset=utf-8">>, <<"access-control-allow-origin">> => <<"*">> }, ErrorJSON, Req0), {ok, Req1, State} end. validate_content_type(Req) -> case cowboy_req:header(<<"content-type">>, Req) of <<"application/json", _/binary>> -> ok; undefined -> ok; % Be lenient for now _ -> {error, invalid_content_type} end. %% Validate JMAP request structure as per RFC 8620 validate_jmap_request(#{<<"using">> := Using, <<"methodCalls">> := MethodCalls}) when is_list(Using), is_list(MethodCalls) -> case validate_using_array(Using) of ok -> validate_method_calls_array(MethodCalls); Error -> Error end; validate_jmap_request(_) -> {error, #{ type => <<"urn:ietf:params:jmap:error:notRequest">>, status => 400, detail => <<"Missing required 'using' or 'methodCalls' properties">> }}. validate_using_array([]) -> {error, #{ type => <<"urn:ietf:params:jmap:error:notRequest">>, status => 400, detail => <<"'using' array cannot be empty">> }}; validate_using_array(Using) -> case lists:all(fun is_binary/1, Using) of true -> ok; false -> {error, #{ type => <<"urn:ietf:params:jmap:error:notRequest">>, status => 400, detail => <<"All capability URIs in 'using' must be strings">> }} end. validate_method_calls_array([]) -> ok; % Empty method calls is valid validate_method_calls_array(MethodCalls) -> case lists:all(fun jchat_utils:validate_method_call/1, MethodCalls) of true -> validate_unique_call_ids(MethodCalls); false -> {error, #{ type => <<"urn:ietf:params:jmap:error:notRequest">>, status => 400, detail => <<"Invalid method call structure">> }} end. validate_unique_call_ids(MethodCalls) -> CallIds = [CallId || [_, _, CallId] <- MethodCalls], case length(CallIds) =:= length(lists:usort(CallIds)) of true -> ok; false -> {error, #{ type => <<"urn:ietf:params:jmap:error:notRequest">>, status => 400, detail => <<"Method call IDs must be unique within request">> }} end. process_jmap_data(#{<<"using">> := Using, <<"methodCalls">> := MethodCalls} = Request, AuthHeader) -> % Validate request structure first case validate_jmap_request(Request) of ok -> % Validate capabilities case validate_capabilities(Using) of ok -> % Authenticate the request case authenticate_jmap_request(AuthHeader) of {ok, AuthContext} -> AccountId = maps:get(user_id, AuthContext), CreatedIds = maps:get(<<"createdIds">>, Request, #{}), process_method_calls(MethodCalls, AccountId, CreatedIds, []); {error, Error} -> Error end; {error, _} = Error -> Error end; {error, _} = Error -> Error end; process_jmap_data(_, _AuthHeader) -> {error, #{ type => <<"urn:ietf:params:jmap:error:notRequest">>, status => 400, detail => <<"Request does not match JMAP Request object schema">> }}. %% Authenticate JMAP API request authenticate_jmap_request(AuthHeader) -> case jchat_utils:extract_auth_token(AuthHeader) of {ok, Token} -> case jchat_auth:validate_token(Token) of {ok, AuthContext} -> {ok, AuthContext}; {error, Error} -> {error, Error} end; {error, no_auth_header} -> {error, #{ type => <<"urn:ietf:params:jmap:error:unauthorized">>, status => 401, detail => <<"Authentication required. Please log in or register.">>, prompt => <<"register">> }}; {error, invalid_auth_format} -> {error, #{ type => <<"urn:ietf:params:jmap:error:unauthorized">>, status => 401, detail => <<"Invalid Authorization header format. Use 'Bearer '">>, prompt => <<"register">> }} end. validate_capabilities(Using) -> SupportedCaps = [ <<"urn:ietf:params:jmap:core">>, <<"urn:ietf:params:jmap:chat">> ], case lists:all(fun(Cap) -> lists:member(Cap, SupportedCaps) end, Using) of true -> ok; false -> {error, #{ type => <<"urn:ietf:params:jmap:error:unknownCapability">>, status => 400, detail => <<"Unknown capability requested">> }} end. process_method_calls([], _AccountId, CreatedIds, Acc) -> {ok, #{ <<"methodResponses">> => lists:reverse(Acc), <<"createdIds">> => CreatedIds, <<"sessionState">> => <<"default-state">> }}; process_method_calls([[Method, Args, CallId] | Rest], AccountId, CreatedIds, Acc) -> case jchat_methods:handle_method(Method, Args, AccountId) of {ok, Response} -> ResponseCall = [Method, Response, CallId], process_method_calls(Rest, AccountId, CreatedIds, [ResponseCall | Acc]); {error, Error} -> ErrorResponse = jchat_utils:format_error(Error), ErrorCall = [<<"error">>, ErrorResponse, CallId], process_method_calls(Rest, AccountId, CreatedIds, [ErrorCall | Acc]) end.