diff options
author | Calvin Morrison <calvin@pobox.com> | 2025-09-03 21:15:36 -0400 |
---|---|---|
committer | Calvin Morrison <calvin@pobox.com> | 2025-09-03 21:15:36 -0400 |
commit | 49fa5aa2a127bdf8924d02bf77e5086b39c7a447 (patch) | |
tree | 61d86a7705dacc9fddccc29fa79d075d83ab8059 /server/src |
Diffstat (limited to 'server/src')
-rw-r--r-- | server/src/jchat.app.src | 21 | ||||
-rw-r--r-- | server/src/jchat_app.erl | 10 | ||||
-rw-r--r-- | server/src/jchat_auth.erl | 433 | ||||
-rw-r--r-- | server/src/jchat_config.erl | 46 | ||||
-rw-r--r-- | server/src/jchat_db.erl | 390 | ||||
-rw-r--r-- | server/src/jchat_dev.erl | 88 | ||||
-rw-r--r-- | server/src/jchat_http.erl | 265 | ||||
-rw-r--r-- | server/src/jchat_http_404.erl | 14 | ||||
-rw-r--r-- | server/src/jchat_http_auth.erl | 155 | ||||
-rw-r--r-- | server/src/jchat_http_download.erl | 7 | ||||
-rw-r--r-- | server/src/jchat_http_eventsource.erl | 7 | ||||
-rw-r--r-- | server/src/jchat_http_health.erl | 21 | ||||
-rw-r--r-- | server/src/jchat_http_redirect.erl | 17 | ||||
-rw-r--r-- | server/src/jchat_http_static.erl | 110 | ||||
-rw-r--r-- | server/src/jchat_http_upload.erl | 7 | ||||
-rw-r--r-- | server/src/jchat_methods.erl | 355 | ||||
-rw-r--r-- | server/src/jchat_presence.erl | 19 | ||||
-rw-r--r-- | server/src/jchat_push.erl | 19 | ||||
-rw-r--r-- | server/src/jchat_sup.erl | 50 | ||||
-rw-r--r-- | server/src/jchat_utils.erl | 163 |
20 files changed, 2197 insertions, 0 deletions
diff --git a/server/src/jchat.app.src b/server/src/jchat.app.src new file mode 100644 index 0000000..d8674b9 --- /dev/null +++ b/server/src/jchat.app.src @@ -0,0 +1,21 @@ +{application, jchat, + [{description, "JMAP-based Chat Server"}, + {vsn, "0.1.0"}, + {registered, []}, + {mod, {jchat_app, []}}, + {applications, + [kernel, + stdlib, + crypto, + inets, + jsx, + cowboy, + mnesia, + bcrypt + ]}, + {env,[]}, + {modules, []}, + + {licenses, ["Apache-2.0"]}, + {links, []} + ]}. diff --git a/server/src/jchat_app.erl b/server/src/jchat_app.erl new file mode 100644 index 0000000..eba0cca --- /dev/null +++ b/server/src/jchat_app.erl @@ -0,0 +1,10 @@ +-module(jchat_app). +-behaviour(application). + +-export([start/2, stop/1]). + +start(_StartType, _StartArgs) -> + jchat_sup:start_link(). + +stop(_State) -> + ok. diff --git a/server/src/jchat_auth.erl b/server/src/jchat_auth.erl new file mode 100644 index 0000000..36d9f39 --- /dev/null +++ b/server/src/jchat_auth.erl @@ -0,0 +1,433 @@ +-module(jchat_auth). + +-export([ + authenticate_request/1, + register_user/3, + login_user/2, + validate_token/1, + generate_jwt/2, + hash_password/1, + verify_password/2, + get_user_by_id/1, + get_user_by_email/1 +]). + +-include("jchat.hrl"). + +%% JWT secret - in production, load from config/environment +-define(JWT_SECRET, <<"your-secret-key-change-this-in-production">>). +-define(TOKEN_EXPIRY_HOURS, 24). + +%% Main authentication entry point +authenticate_request(Req) -> + case cowboy_req:header(<<"authorization">>, Req) of + undefined -> + {error, #{ + type => <<"unauthorized">>, + status => 401, + detail => <<"Missing Authorization header">>, + prompt => <<"register">> % Signal to show registration prompt + }}; + AuthHeader -> + case jchat_utils:extract_auth_token(AuthHeader) of + {ok, Token} -> + validate_token(Token); + {error, invalid_auth_format} -> + {error, #{ + type => <<"unauthorized">>, + status => 401, + detail => <<"Invalid Authorization header format. Use 'Bearer <token>'">>, + prompt => <<"register">> + }}; + {error, no_auth_header} -> + {error, #{ + type => <<"unauthorized">>, + status => 401, + detail => <<"Missing Authorization header">>, + prompt => <<"register">> + }} + end + end. + +%% Register a new user +register_user(Email, Password, DisplayName) -> + case validate_registration_data(Email, Password, DisplayName) of + ok -> + case get_user_by_email(Email) of + {ok, _ExistingUser} -> + {error, #{ + type => <<"userExists">>, + status => 400, + detail => <<"User with this email already exists">> + }}; + {error, not_found} -> + UserId = jchat_utils:generate_id(), + PasswordHash = hash_password(Password), + User = #user{ + id = UserId, + email = Email, + password_hash = PasswordHash, + display_name = DisplayName, + created_at = jchat_utils:now_iso8601(), + is_active = true, + auth_provider = <<"local">>, + auth_provider_id = null + }, + + case jchat_db:create_user(User) of + {ok, CreatedUser} -> + Token = generate_jwt(UserId, Email), + {ok, #{ + <<"user">> => user_to_json(CreatedUser), + <<"token">> => Token, + <<"tokenType">> => <<"Bearer">>, + <<"expiresIn">> => ?TOKEN_EXPIRY_HOURS * 3600 + }}; + {error, Reason} -> + {error, #{ + type => <<"serverFail">>, + status => 500, + detail => <<"Failed to create user: ", (iolist_to_binary(io_lib:format("~p", [Reason])))/binary>> + }} + end; + {error, Reason} -> + {error, #{ + type => <<"serverFail">>, + status => 500, + detail => <<"Database error: ", (iolist_to_binary(io_lib:format("~p", [Reason])))/binary>> + }} + end; + {error, ValidationError} -> + {error, ValidationError} + end. + +%% Login existing user +login_user(Email, Password) -> + case get_user_by_email(Email) of + {ok, User} -> + case verify_password(Password, User#user.password_hash) of + true -> + case User#user.is_active of + true -> + Token = generate_jwt(User#user.id, Email), + % Update last login time + UpdatedUser = User#user{last_login_at = jchat_utils:now_iso8601()}, + jchat_db:update_user(UpdatedUser), + {ok, #{ + <<"user">> => user_to_json(UpdatedUser), + <<"token">> => Token, + <<"tokenType">> => <<"Bearer">>, + <<"expiresIn">> => ?TOKEN_EXPIRY_HOURS * 3600 + }}; + false -> + {error, #{ + type => <<"accountDisabled">>, + status => 403, + detail => <<"Account is disabled">> + }} + end; + false -> + {error, #{ + type => <<"invalidCredentials">>, + status => 401, + detail => <<"Invalid email or password">> + }} + end; + {error, not_found} -> + {error, #{ + type => <<"invalidCredentials">>, + status => 401, + detail => <<"Invalid email or password">> + }}; + {error, Reason} -> + {error, #{ + type => <<"serverFail">>, + status => 500, + detail => <<"Database error: ", (iolist_to_binary(io_lib:format("~p", [Reason])))/binary>> + }} + end. + +%% Validate JWT token +validate_token(Token) -> + try + case jwt:decode(Token, ?JWT_SECRET) of + {ok, Claims} -> + case validate_token_claims(Claims) of + {ok, UserId, Email} -> + case get_user_by_id(UserId) of + {ok, User} -> + case User#user.is_active of + true -> + {ok, #{ + user_id => UserId, + email => Email, + user => User + }}; + false -> + {error, #{ + type => <<"accountDisabled">>, + status => 403, + detail => <<"Account is disabled">> + }} + end; + {error, not_found} -> + {error, #{ + type => <<"invalidToken">>, + status => 401, + detail => <<"User no longer exists">> + }}; + {error, Reason} -> + {error, #{ + type => <<"serverFail">>, + status => 500, + detail => <<"Database error: ", (iolist_to_binary(io_lib:format("~p", [Reason])))/binary>> + }} + end; + {error, Reason} -> + {error, #{ + type => <<"invalidToken">>, + status => 401, + detail => Reason + }} + end; + {error, _Reason} -> + {error, #{ + type => <<"invalidToken">>, + status => 401, + detail => <<"Invalid or malformed token">>, + prompt => <<"register">> + }} + end + catch + _:_ -> + {error, #{ + type => <<"invalidToken">>, + status => 401, + detail => <<"Token validation failed">>, + prompt => <<"register">> + }} + end. + +%% Generate JWT token +generate_jwt(UserId, Email) -> + Now = erlang:system_time(second), + Expiry = Now + (?TOKEN_EXPIRY_HOURS * 3600), + Claims = #{ + <<"sub">> => UserId, + <<"email">> => Email, + <<"iat">> => Now, + <<"exp">> => Expiry, + <<"iss">> => <<"jchat-server">> + }, + {ok, Token} = jwt:encode(<<"HS256">>, Claims, ?JWT_SECRET), + Token. + +%% Hash password using bcrypt with fallback +hash_password(Password) -> + try + Salt = bcrypt:gen_salt(), + Hash = bcrypt:hashpw(Password, Salt), + <<"bcrypt$", Hash/binary>> + catch + _:_ -> + % Fallback to crypto-based hashing + logger:warning("bcrypt failed, using crypto fallback for password hashing"), + crypto_hash_password(Password) + end. + +%% Verify password against hash +verify_password(Password, Hash) -> + case Hash of + <<"bcrypt$", BcryptHash/binary>> -> + try + bcrypt:verify(Password, BcryptHash) + catch + _:_ -> + logger:warning("bcrypt verify failed"), + false + end; + <<"crypto$", CryptoHash/binary>> -> + crypto_verify_password(Password, CryptoHash); + _ -> + % Legacy bcrypt hash without prefix + try + bcrypt:verify(Password, Hash) + catch + _:_ -> + false + end + end. + +%% Fallback crypto-based password hashing +crypto_hash_password(Password) -> + Salt = crypto:strong_rand_bytes(16), + Hash = crypto:hash(sha256, <<Salt/binary, Password/binary>>), + SaltHex = hex_encode(Salt), + HashHex = hex_encode(Hash), + <<"crypto$", SaltHex/binary, "$", HashHex/binary>>. + +%% Verify crypto-based password +crypto_verify_password(Password, CryptoHash) -> + case binary:split(CryptoHash, <<"$">>) of + [SaltHex, HashHex] -> + try + Salt = hex_decode(SaltHex), + ExpectedHash = hex_decode(HashHex), + ActualHash = crypto:hash(sha256, <<Salt/binary, Password/binary>>), + ActualHash =:= ExpectedHash + catch + _:_ -> + false + end; + _ -> + false + end. + +%% Hex encoding/decoding helpers +hex_encode(Binary) -> + << <<(hex_char(N div 16)), (hex_char(N rem 16))>> || <<N>> <= Binary >>. + +hex_decode(Hex) -> + << <<(hex_to_int(H1) * 16 + hex_to_int(H2))>> || <<H1, H2>> <= Hex >>. + +hex_char(N) when N < 10 -> $0 + N; +hex_char(N) -> $a + N - 10. + +hex_to_int(C) when C >= $0, C =< $9 -> C - $0; +hex_to_int(C) when C >= $a, C =< $f -> C - $a + 10; +hex_to_int(C) when C >= $A, C =< $F -> C - $A + 10. + +%% Get user by ID +get_user_by_id(UserId) -> + jchat_db:get_user_by_id(UserId). + +%% Get user by email +get_user_by_email(Email) -> + jchat_db:get_user_by_email(Email). + +%% Private helper functions + +validate_registration_data(Email, Password, DisplayName) -> + case validate_email(Email) of + ok -> + case validate_password(Password) of + ok -> + case validate_display_name(DisplayName) of + ok -> ok; + Error -> Error + end; + Error -> Error + end; + Error -> Error + end. + +validate_email(Email) when is_binary(Email) -> + EmailStr = binary_to_list(Email), + case re:run(EmailStr, "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$") of + {match, _} -> + case byte_size(Email) =< 255 of + true -> ok; + false -> {error, #{ + type => <<"invalidArguments">>, + status => 400, + detail => <<"Email address is too long">> + }} + end; + nomatch -> + {error, #{ + type => <<"invalidArguments">>, + status => 400, + detail => <<"Invalid email address format">> + }} + end; +validate_email(_) -> + {error, #{ + type => <<"invalidArguments">>, + status => 400, + detail => <<"Email must be a string">> + }}. + +validate_password(Password) when is_binary(Password) -> + case byte_size(Password) of + Size when Size >= 8, Size =< 128 -> + ok; + Size when Size < 8 -> + {error, #{ + type => <<"invalidArguments">>, + status => 400, + detail => <<"Password must be at least 8 characters long">> + }}; + _ -> + {error, #{ + type => <<"invalidArguments">>, + status => 400, + detail => <<"Password is too long">> + }} + end; +validate_password(_) -> + {error, #{ + type => <<"invalidArguments">>, + status => 400, + detail => <<"Password must be a string">> + }}. + +validate_display_name(DisplayName) when is_binary(DisplayName) -> + case byte_size(DisplayName) of + Size when Size >= 1, Size =< 100 -> + % Check for valid characters (letters, numbers, spaces, basic punctuation) + case re:run(DisplayName, "^[a-zA-Z0-9 ._-]+$", [unicode]) of + {match, _} -> ok; + nomatch -> {error, #{ + type => <<"invalidArguments">>, + status => 400, + detail => <<"Display name contains invalid characters">> + }} + end; + Size when Size < 1 -> + {error, #{ + type => <<"invalidArguments">>, + status => 400, + detail => <<"Display name cannot be empty">> + }}; + _ -> + {error, #{ + type => <<"invalidArguments">>, + status => 400, + detail => <<"Display name is too long">> + }} + end; +validate_display_name(_) -> + {error, #{ + type => <<"invalidArguments">>, + status => 400, + detail => <<"Display name must be a string">> + }}. + +validate_token_claims(Claims) -> + try + UserId = maps:get(<<"sub">>, Claims), + Email = maps:get(<<"email">>, Claims), + Expiry = maps:get(<<"exp">>, Claims), + Now = erlang:system_time(second), + + case Expiry > Now of + true -> + {ok, UserId, Email}; + false -> + {error, <<"Token has expired">>} + end + catch + _:_ -> + {error, <<"Invalid token claims">>} + end. + +user_to_json(User) -> + #{ + <<"id">> => User#user.id, + <<"email">> => User#user.email, + <<"displayName">> => User#user.display_name, + <<"createdAt">> => User#user.created_at, + <<"lastLoginAt">> => User#user.last_login_at, + <<"isActive">> => User#user.is_active, + <<"authProvider">> => User#user.auth_provider + }. diff --git a/server/src/jchat_config.erl b/server/src/jchat_config.erl new file mode 100644 index 0000000..35a3436 --- /dev/null +++ b/server/src/jchat_config.erl @@ -0,0 +1,46 @@ +-module(jchat_config). + +-export([get/1, get/2, + http_port/0, api_domain/0, web_domain/0, + static_files_dir/0, cors_origins/0, + jwt_secret/0, auth_config/0, + database_config/0]). + +%% Get configuration value with optional default +get(Key) -> + application:get_env(jchat, Key). + +get(Key, Default) -> + application:get_env(jchat, Key, Default). + +%% Specific configuration getters +http_port() -> + get(http_port, 8080). + +api_domain() -> + get(api_domain, "api.jchat.localhost"). + +web_domain() -> + get(web_domain, "web.jchat.localhost"). + +static_files_dir() -> + get(static_files_dir, "../client"). + +cors_origins() -> + get(cors_origins, ["*"]). + +jwt_secret() -> + Secret = get(jwt_secret, "default-secret-change-me"), + case Secret of + "default-secret-change-me" -> + logger:warning("Using default JWT secret - change this in production!"), + Secret; + _ -> + Secret + end. + +auth_config() -> + get(auth, []). + +database_config() -> + get(database, []). diff --git a/server/src/jchat_db.erl b/server/src/jchat_db.erl new file mode 100644 index 0000000..65a76f4 --- /dev/null +++ b/server/src/jchat_db.erl @@ -0,0 +1,390 @@ +-module(jchat_db). + +-export([init/0, + create_tables/0, + create_table/2, + init_state_counters/0, + get_user/1, create_user/1, update_user/1, get_user_by_id/1, get_user_by_email/1, + get_conversation/1, create_conversation/2, update_conversation/2, + get_message/1, create_message/2, update_message/2, + get_participant/1, create_participant/2, update_participant/2, + get_presence/1, update_presence/2, + query_conversations/2, query_messages/2]). + +-include("jchat.hrl"). + +%% Initialize database +init() -> + case mnesia:create_schema([node()]) of + ok -> ok; + {error, {_, {already_exists, _}}} -> ok; + Error -> error({schema_creation_failed, Error}) + end, + mnesia:start(), + create_tables(), + ok. + +%% Create database tables +create_tables() -> + % Users table + create_table(user, [ + {attributes, record_info(fields, user)}, + {ram_copies, [node()]}, + {type, set} + ]), + + % Conversations table + create_table(conversation, [ + {attributes, record_info(fields, conversation)}, + {ram_copies, [node()]}, + {type, set} + ]), + + % Messages table + create_table(message, [ + {attributes, record_info(fields, message)}, + {ram_copies, [node()]}, + {type, set} + ]), + + % Participants table + create_table(participant, [ + {attributes, record_info(fields, participant)}, + {ram_copies, [node()]}, + {type, set} + ]), + + % Presence table + create_table(presence, [ + {attributes, record_info(fields, presence)}, + {ram_copies, [node()]}, + {type, set} + ]), + + % State counters for JMAP + create_table(state_counter, [ + {attributes, record_info(fields, state_counter)}, + {ram_copies, [node()]}, + {type, set} + ]), + + mnesia:wait_for_tables([user, conversation, message, participant, + presence, state_counter], 5000), + + % Initialize state counters + init_state_counters(). + +%% Helper function to create table with error handling +create_table(TableName, Options) -> + case mnesia:create_table(TableName, Options) of + {atomic, ok} -> ok; + {aborted, {already_exists, TableName}} -> ok; + Error -> error({table_creation_failed, TableName, Error}) + end. + +%% User operations +get_user(Id) -> + get_user_by_id(Id). + +get_user_by_id(Id) -> + case mnesia:dirty_read(user, Id) of + [User] -> {ok, User}; + [] -> {error, not_found} + end. + +get_user_by_email(Email) -> + MatchSpec = [{#user{email = '$1', _ = '_'}, [{'=:=', '$1', Email}], ['$_']}], + case mnesia:dirty_select(user, MatchSpec) of + [User] -> {ok, User}; + [] -> {error, not_found}; + Users -> {ok, hd(Users)} % Take first match if multiple (shouldn't happen) + end. + +create_user(User) when is_record(User, user) -> + case mnesia:dirty_write(User) of + ok -> {ok, User}; + Error -> {error, Error} + end. + +update_user(User) when is_record(User, user) -> + case mnesia:dirty_write(User) of + ok -> {ok, User}; + Error -> {error, Error} + end. + +%% Conversation operations +get_conversation(Id) -> + case mnesia:dirty_read(conversation, Id) of + [Conv] -> {ok, Conv}; + [] -> {error, not_found} + end. + +create_conversation(Id, Attrs) -> + Now = jchat_utils:now_iso8601(), + Conv = #conversation{ + id = Id, + title = maps:get(<<"title">>, Attrs, null), + description = maps:get(<<"description">>, Attrs, null), + created_at = Now, + updated_at = Now, + is_archived = maps:get(<<"isArchived">>, Attrs, false), + is_muted = maps:get(<<"isMuted">>, Attrs, false), + participant_ids = maps:get(<<"participantIds">>, Attrs, []), + last_message_id = null, + last_message_at = null, + unread_count = 0, + message_count = 0, + metadata = maps:get(<<"metadata">>, Attrs, null) + }, + case mnesia:dirty_write(Conv) of + ok -> + update_state_counter(conversation), + {ok, Conv}; + Error -> Error + end. + +update_conversation(Id, Updates) -> + case mnesia:dirty_read(conversation, Id) of + [Conv] -> + UpdatedConv = apply_updates(Conv, Updates), + UpdatedConv2 = UpdatedConv#conversation{updated_at = jchat_utils:now_iso8601()}, + case mnesia:dirty_write(UpdatedConv2) of + ok -> + update_state_counter(conversation), + {ok, UpdatedConv2}; + Error -> Error + end; + [] -> + {error, not_found} + end. + +%% Message operations +get_message(Id) -> + case mnesia:dirty_read(message, Id) of + [Msg] -> {ok, Msg}; + [] -> {error, not_found} + end. + +create_message(Id, Attrs) -> + Now = jchat_utils:now_iso8601(), + ConvId = maps:get(<<"conversationId">>, Attrs), + Msg = #message{ + id = Id, + conversation_id = ConvId, + sender_id = maps:get(<<"senderId">>, Attrs, <<"unknown">>), + sent_at = Now, + received_at = Now, + edited_at = null, + body = maps:get(<<"body">>, Attrs), + body_type = maps:get(<<"bodyType">>, Attrs, <<"text/plain">>), + attachments = maps:get(<<"attachments">>, Attrs, null), + reply_to_message_id = maps:get(<<"replyToMessageId">>, Attrs, null), + is_system_message = maps:get(<<"isSystemMessage">>, Attrs, false), + is_deleted = false, + reactions = null, + delivery_status = <<"sent">>, + read_by = [], + metadata = maps:get(<<"metadata">>, Attrs, null) + }, + case mnesia:dirty_write(Msg) of + ok -> + % Update conversation with new message + update_conversation_last_message(ConvId, Id, Now), + update_state_counter(message), + {ok, Msg}; + Error -> Error + end. + +update_message(Id, Updates) -> + case mnesia:dirty_read(message, Id) of + [Msg] -> + UpdatedMsg = apply_updates(Msg, Updates), + UpdatedMsg2 = case maps:get(body, Updates, undefined) of + undefined -> UpdatedMsg; + _ -> UpdatedMsg#message{edited_at = jchat_utils:now_iso8601()} + end, + case mnesia:dirty_write(UpdatedMsg2) of + ok -> + update_state_counter(message), + {ok, UpdatedMsg2}; + Error -> Error + end; + [] -> + {error, not_found} + end. + +%% Participant operations +get_participant(Id) -> + case mnesia:dirty_read(participant, Id) of + [Part] -> {ok, Part}; + [] -> {error, not_found} + end. + +create_participant(Id, Attrs) -> + Now = jchat_utils:now_iso8601(), + Part = #participant{ + id = Id, + conversation_id = maps:get(conversation_id, Attrs), + user_id = maps:get(user_id, Attrs), + display_name = maps:get(display_name, Attrs), + avatar_blob_id = maps:get(avatar_blob_id, Attrs, null), + role = maps:get(role, Attrs, <<"member">>), + joined_at = Now, + last_active_at = null, + is_active = true, + permissions = maps:get(permissions, Attrs, [<<"send">>]), + metadata = maps:get(metadata, Attrs, null) + }, + case mnesia:dirty_write(Part) of + ok -> + update_state_counter(participant), + {ok, Part}; + Error -> Error + end. + +update_participant(Id, Updates) -> + case mnesia:dirty_read(participant, Id) of + [Part] -> + UpdatedPart = apply_updates(Part, Updates), + case mnesia:dirty_write(UpdatedPart) of + ok -> + update_state_counter(participant), + {ok, UpdatedPart}; + Error -> Error + end; + [] -> + {error, not_found} + end. + +%% Presence operations +get_presence(UserId) -> + case mnesia:dirty_read(presence, UserId) of + [Pres] -> {ok, Pres}; + [] -> {error, not_found} + end. + +update_presence(UserId, Updates) -> + Now = jchat_utils:now_iso8601(), + Presence = case mnesia:dirty_read(presence, UserId) of + [Existing] -> apply_updates(Existing, Updates); + [] -> #presence{ + user_id = UserId, + status = maps:get(status, Updates, <<"offline">>), + status_message = maps:get(status_message, Updates, null), + last_seen_at = null, + updated_at = Now + } + end, + UpdatedPresence = Presence#presence{updated_at = Now}, + case mnesia:dirty_write(UpdatedPresence) of + ok -> + update_state_counter(presence), + {ok, UpdatedPresence}; + Error -> Error + end. + +%% Query operations +query_conversations(UserId, Filter) -> + MatchSpec = build_conversation_match_spec(UserId, Filter), + Conversations = mnesia:dirty_select(conversation, MatchSpec), + {ok, Conversations}. + +query_messages(Filter, Sort) -> + MatchSpec = build_message_match_spec(Filter), + Messages = mnesia:dirty_select(message, MatchSpec), + SortedMessages = sort_messages(Messages, Sort), + {ok, SortedMessages}. + +%% Internal functions +apply_updates(Record, Updates) -> + Fields = case Record of + #conversation{} -> record_info(fields, conversation); + #message{} -> record_info(fields, message); + #participant{} -> record_info(fields, participant); + #presence{} -> record_info(fields, presence) + end, + apply_updates(Record, Updates, Fields, 2). + +apply_updates(Record, _Updates, [], _Index) -> + Record; +apply_updates(Record, Updates, [Field|Rest], Index) -> + UpdatedRecord = case maps:get(Field, Updates, undefined) of + undefined -> Record; + Value -> setelement(Index, Record, Value) + end, + apply_updates(UpdatedRecord, Updates, Rest, Index + 1). + +update_conversation_last_message(ConvId, MsgId, Timestamp) -> + case mnesia:dirty_read(conversation, ConvId) of + [Conv] -> + UpdatedConv = Conv#conversation{ + last_message_id = MsgId, + last_message_at = Timestamp, + message_count = Conv#conversation.message_count + 1, + updated_at = jchat_utils:now_iso8601() + }, + mnesia:dirty_write(UpdatedConv); + [] -> + ok + end. + +update_state_counter(Type) -> + Key = {<<"default">>, Type}, % {account_id, object_type} + case mnesia:dirty_read(state_counter, Key) of + [#state_counter{state = State}] -> + NewState = integer_to_binary(binary_to_integer(State) + 1), + Now = jchat_utils:now_iso8601(), + mnesia:dirty_write(#state_counter{ + account_id = <<"default">>, + object_type = Type, + state = NewState, + updated_at = Now + }); + [] -> + Now = jchat_utils:now_iso8601(), + mnesia:dirty_write(#state_counter{ + account_id = <<"default">>, + object_type = Type, + state = <<"1">>, + updated_at = Now + }) + end. + +build_conversation_match_spec(_UserId, _Filter) -> + % Simplified - in production would build proper match specs + [{'$1', [], ['$1']}]. + +build_message_match_spec(Filter) -> + case maps:get(<<"inConversation">>, Filter, null) of + null -> + % No filter - return all messages + [{'$1', [], ['$1']}]; + ConversationId -> + % Filter by conversation ID + % Match: #message{conversation_id = ConversationId, _ = '_'} + [{{message, '_', ConversationId, '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_'}, [], ['$_']}] + end. + +sort_messages(Messages, _Sort) -> + % Simplified - in production would implement proper sorting + Messages. + +%% Initialize state counters for JMAP +init_state_counters() -> + Types = [conversation, message, participant, presence], + lists:foreach(fun(Type) -> + Key = {<<"default">>, Type}, % {account_id, object_type} + case mnesia:dirty_read(state_counter, Key) of + [] -> + Now = jchat_utils:now_iso8601(), + Counter = #state_counter{ + account_id = <<"default">>, + object_type = Type, + state = <<"0">>, + updated_at = Now + }, + mnesia:dirty_write(Counter); + _ -> + ok % Already exists + end + end, Types). diff --git a/server/src/jchat_dev.erl b/server/src/jchat_dev.erl new file mode 100644 index 0000000..6178c20 --- /dev/null +++ b/server/src/jchat_dev.erl @@ -0,0 +1,88 @@ +-module(jchat_dev). + +-export([seed_data/0, create_sample_conversations/0]). + +-include("jchat.hrl"). + +%% Create sample data for development/testing +seed_data() -> + create_sample_users(), + create_sample_conversations(), + ok. + +%% Create sample users +create_sample_users() -> + Users = [ + {<<"user1">>, <<"Alice">>}, + {<<"user2">>, <<"Bob">>}, + {<<"user3">>, <<"Charlie">>} + ], + + lists:foreach(fun({UserId, Username}) -> + jchat_db:create_user(UserId, #{ + <<"username">> => Username + }) + end, Users). + +%% Create sample conversations +create_sample_conversations() -> + % Conversation 1: General chat + Conv1Id = jchat_utils:generate_id(), + jchat_db:create_conversation(Conv1Id, #{ + title => <<"General Chat">>, + description => <<"General discussion for everyone">>, + is_archived => false, + is_muted => false, + participant_ids => [<<"user1">>, <<"user2">>, <<"user3">>], + metadata => #{} + }), + + % Add some messages to conversation 1 + create_sample_messages(Conv1Id, [ + {<<"user1">>, <<"Hello everyone! 👋">>}, + {<<"user2">>, <<"Hey Alice! How's it going?">>}, + {<<"user3">>, <<"Good morning all!">>} + ]), + + % Conversation 2: Project discussion + Conv2Id = jchat_utils:generate_id(), + jchat_db:create_conversation(Conv2Id, #{ + title => <<"JCHAT Project">>, + description => <<"Discussion about the JCHAT implementation">>, + is_archived => false, + is_muted => false, + participant_ids => [<<"user1">>, <<"user2">>], + metadata => #{} + }), + + % Add some messages to conversation 2 + create_sample_messages(Conv2Id, [ + {<<"user1">>, <<"The server is working great now!">>}, + {<<"user2">>, <<"Awesome! Ready to test the client integration.">>} + ]), + + ok. + +%% Helper to create sample messages +create_sample_messages(ConversationId, Messages) -> + lists:foldl(fun({SenderId, Body}, N) -> + MessageId = jchat_utils:generate_id(), + jchat_db:create_message(MessageId, #{ + conversation_id => ConversationId, + sender_id => SenderId, + body => Body, + body_type => <<"text/plain">>, + attachments => null, + reply_to_message_id => null, + is_system_message => false, + metadata => #{} + }), + + % Update conversation with last message info + jchat_db:update_conversation(ConversationId, #{ + last_message_id => MessageId, + message_count => N + 1 + }), + + N + 1 + end, 0, Messages). diff --git a/server/src/jchat_http.erl b/server/src/jchat_http.erl new file mode 100644 index 0000000..f0d0772 --- /dev/null +++ b/server/src/jchat_http.erl @@ -0,0 +1,265 @@ +-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 <token>'">>, + 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. diff --git a/server/src/jchat_http_404.erl b/server/src/jchat_http_404.erl new file mode 100644 index 0000000..03f29cf --- /dev/null +++ b/server/src/jchat_http_404.erl @@ -0,0 +1,14 @@ +-module(jchat_http_404). + +-export([init/2]). + +init(Req0, State) -> + Req1 = cowboy_req:reply(404, #{ + <<"content-type">> => <<"application/json; charset=utf-8">>, + <<"access-control-allow-origin">> => <<"*">> + }, jsx:encode(#{ + <<"error">> => <<"not_found">>, + <<"message">> => <<"Endpoint not found">>, + <<"suggestion">> => <<"Use web.jchat.localhost for the web interface">> + }), Req0), + {ok, Req1, State}. diff --git a/server/src/jchat_http_auth.erl b/server/src/jchat_http_auth.erl new file mode 100644 index 0000000..b59fa4c --- /dev/null +++ b/server/src/jchat_http_auth.erl @@ -0,0 +1,155 @@ +-module(jchat_http_auth). + +-export([init/2]). + +-include("jchat.hrl"). + +init(Req0, State) -> + Method = cowboy_req:method(Req0), + Path = cowboy_req:path(Req0), + handle_request(Method, Path, Req0, State). + +%% Handle registration endpoint +handle_request(<<"POST">>, <<"/auth/register">>, Req0, State) -> + case cowboy_req:read_body(Req0) of + {ok, Body, Req1} -> + process_registration(Body, Req1, State); + {more, _Data, Req1} -> + reply_error(413, <<"requestTooLarge">>, <<"Request body too large">>, Req1, State) + end; + +%% Handle login endpoint +handle_request(<<"POST">>, <<"/auth/login">>, Req0, State) -> + case cowboy_req:read_body(Req0) of + {ok, Body, Req1} -> + process_login(Body, Req1, State); + {more, _Data, Req1} -> + reply_error(413, <<"requestTooLarge">>, <<"Request body too large">>, Req1, State) + end; + +%% Handle logout endpoint +handle_request(<<"POST">>, <<"/auth/logout">>, Req0, State) -> + % For JWT-based auth, logout is mainly client-side + % But we can implement token blacklisting here in the future + Req1 = cowboy_req:reply(200, #{ + <<"content-type">> => <<"application/json; charset=utf-8">>, + <<"access-control-allow-origin">> => <<"*">> + }, jchat_utils:json_encode(#{<<"success">> => true}), Req0), + {ok, Req1, State}; + +%% Handle token validation endpoint +handle_request(<<"GET">>, <<"/auth/me">>, Req0, State) -> + case jchat_auth:authenticate_request(Req0) of + {ok, AuthContext} -> + User = maps:get(user, AuthContext), + UserJson = #{ + <<"id">> => User#user.id, + <<"email">> => User#user.email, + <<"displayName">> => User#user.display_name, + <<"createdAt">> => User#user.created_at, + <<"lastLoginAt">> => User#user.last_login_at, + <<"isActive">> => User#user.is_active + }, + Req1 = cowboy_req:reply(200, #{ + <<"content-type">> => <<"application/json; charset=utf-8">>, + <<"access-control-allow-origin">> => <<"*">> + }, jchat_utils:json_encode(#{<<"user">> => UserJson}), Req0), + {ok, Req1, State}; + {error, Error} -> + Status = maps:get(status, Error, 401), + Type = maps:get(type, Error, <<"unauthorized">>), + Detail = maps:get(detail, Error, <<"Authentication required">>), + reply_error(Status, Type, Detail, Req0, State) + end; + +%% Handle OPTIONS requests for CORS +handle_request(<<"OPTIONS">>, _Path, Req0, State) -> + Req1 = cowboy_req:reply(200, #{ + <<"access-control-allow-origin">> => <<"*">>, + <<"access-control-allow-methods">> => <<"POST, GET, OPTIONS">>, + <<"access-control-allow-headers">> => <<"content-type, authorization">>, + <<"access-control-max-age">> => <<"86400">> + }, <<>>, Req0), + {ok, Req1, State}; + +%% Handle unsupported methods/paths +handle_request(_Method, _Path, Req0, State) -> + reply_error(404, <<"notFound">>, <<"Endpoint not found">>, Req0, State). + +%% Process user registration +process_registration(Body, Req0, State) -> + case jchat_utils:json_decode(Body) of + {ok, Data} -> + Email = maps:get(<<"email">>, Data, undefined), + Password = maps:get(<<"password">>, Data, undefined), + DisplayName = maps:get(<<"displayName">>, Data, undefined), + + case {Email, Password, DisplayName} of + {undefined, _, _} -> + reply_error(400, <<"invalidArguments">>, <<"Email is required">>, Req0, State); + {_, undefined, _} -> + reply_error(400, <<"invalidArguments">>, <<"Password is required">>, Req0, State); + {_, _, undefined} -> + reply_error(400, <<"invalidArguments">>, <<"Display name is required">>, Req0, State); + {_, _, _} -> + case jchat_auth:register_user(Email, Password, DisplayName) of + {ok, Result} -> + Req1 = cowboy_req:reply(201, #{ + <<"content-type">> => <<"application/json; charset=utf-8">>, + <<"access-control-allow-origin">> => <<"*">> + }, jchat_utils:json_encode(Result), Req0), + {ok, Req1, State}; + {error, Error} -> + Status = maps:get(status, Error, 400), + Type = maps:get(type, Error, <<"registrationFailed">>), + Detail = maps:get(detail, Error, <<"Registration failed">>), + reply_error(Status, Type, Detail, Req0, State) + end + end; + {error, invalid_json} -> + reply_error(400, <<"invalidJSON">>, <<"Request body must be valid JSON">>, Req0, State) + end. + +%% Process user login +process_login(Body, Req0, State) -> + case jchat_utils:json_decode(Body) of + {ok, Data} -> + Email = maps:get(<<"email">>, Data, undefined), + Password = maps:get(<<"password">>, Data, undefined), + + case {Email, Password} of + {undefined, _} -> + reply_error(400, <<"invalidArguments">>, <<"Email is required">>, Req0, State); + {_, undefined} -> + reply_error(400, <<"invalidArguments">>, <<"Password is required">>, Req0, State); + {_, _} -> + case jchat_auth:login_user(Email, Password) of + {ok, Result} -> + Req1 = cowboy_req:reply(200, #{ + <<"content-type">> => <<"application/json; charset=utf-8">>, + <<"access-control-allow-origin">> => <<"*">> + }, jchat_utils:json_encode(Result), Req0), + {ok, Req1, State}; + {error, Error} -> + Status = maps:get(status, Error, 401), + Type = maps:get(type, Error, <<"loginFailed">>), + Detail = maps:get(detail, Error, <<"Login failed">>), + reply_error(Status, Type, Detail, Req0, State) + end + end; + {error, invalid_json} -> + reply_error(400, <<"invalidJSON">>, <<"Request body must be valid JSON">>, Req0, State) + end. + +%% Helper function to send error responses +reply_error(Status, Type, Detail, Req0, State) -> + ErrorResponse = #{ + <<"type">> => Type, + <<"detail">> => Detail, + <<"status">> => Status + }, + Req1 = cowboy_req:reply(Status, #{ + <<"content-type">> => <<"application/json; charset=utf-8">>, + <<"access-control-allow-origin">> => <<"*">> + }, jchat_utils:json_encode(ErrorResponse), Req0), + {ok, Req1, State}. diff --git a/server/src/jchat_http_download.erl b/server/src/jchat_http_download.erl new file mode 100644 index 0000000..af36107 --- /dev/null +++ b/server/src/jchat_http_download.erl @@ -0,0 +1,7 @@ +-module(jchat_http_download). +-export([init/2]). + +init(Req0, State) -> + % TODO: Implement file download + Req1 = cowboy_req:reply(501, #{}, <<"Download not implemented">>, Req0), + {ok, Req1, State}. diff --git a/server/src/jchat_http_eventsource.erl b/server/src/jchat_http_eventsource.erl new file mode 100644 index 0000000..3767987 --- /dev/null +++ b/server/src/jchat_http_eventsource.erl @@ -0,0 +1,7 @@ +-module(jchat_http_eventsource). +-export([init/2]). + +init(Req0, State) -> + % TODO: Implement Server-Sent Events for push notifications + Req1 = cowboy_req:reply(501, #{}, <<"EventSource not implemented">>, Req0), + {ok, Req1, State}. diff --git a/server/src/jchat_http_health.erl b/server/src/jchat_http_health.erl new file mode 100644 index 0000000..4140f51 --- /dev/null +++ b/server/src/jchat_http_health.erl @@ -0,0 +1,21 @@ +-module(jchat_http_health). + +-export([init/2]). + +init(Req0, State) -> + Health = #{ + <<"status">> => <<"ok">>, + <<"timestamp">> => jchat_utils:now_iso8601(), + <<"version">> => <<"0.1.0">>, + <<"config">> => #{ + <<"api_domain">> => list_to_binary(jchat_config:api_domain()), + <<"web_domain">> => list_to_binary(jchat_config:web_domain()) + } + }, + + Req1 = cowboy_req:reply(200, #{ + <<"content-type">> => <<"application/json; charset=utf-8">>, + <<"access-control-allow-origin">> => <<"*">> + }, jchat_utils:json_encode(Health), Req0), + + {ok, Req1, State}. diff --git a/server/src/jchat_http_redirect.erl b/server/src/jchat_http_redirect.erl new file mode 100644 index 0000000..9ad5a5e --- /dev/null +++ b/server/src/jchat_http_redirect.erl @@ -0,0 +1,17 @@ +-module(jchat_http_redirect). + +-export([init/2]). + +init(Req0, State) -> + WebDomain = proplists:get_value(web_domain, State, "web.jchat.localhost"), + Path = cowboy_req:path(Req0), + + % Redirect to web domain + RedirectUrl = iolist_to_binary(["http://", WebDomain, Path]), + + Req1 = cowboy_req:reply(301, #{ + <<"location">> => RedirectUrl, + <<"content-type">> => <<"text/html; charset=utf-8">> + }, <<"<html><body>Redirecting to <a href=\"", RedirectUrl/binary, "\">", RedirectUrl/binary, "</a></body></html>">>, Req0), + + {ok, Req1, State}. diff --git a/server/src/jchat_http_static.erl b/server/src/jchat_http_static.erl new file mode 100644 index 0000000..e11a9c0 --- /dev/null +++ b/server/src/jchat_http_static.erl @@ -0,0 +1,110 @@ +-module(jchat_http_static). + +-export([init/2]). + +init(Req0, State) -> + Method = cowboy_req:method(Req0), + handle_request(Method, Req0, State). + +handle_request(<<"GET">>, Req0, State) -> + Path = cowboy_req:path(Req0), + serve_static_file(Path, Req0, State); + +handle_request(<<"HEAD">>, Req0, State) -> + Path = cowboy_req:path(Req0), + serve_static_file(Path, Req0, State); + +handle_request(<<"OPTIONS">>, Req0, State) -> + Req1 = cowboy_req:reply(200, #{ + <<"access-control-allow-origin">> => <<"*">>, + <<"access-control-allow-methods">> => <<"GET, HEAD, OPTIONS">>, + <<"access-control-allow-headers">> => <<"content-type">> + }, <<>>, Req0), + {ok, Req1, State}; + +handle_request(_Method, Req0, State) -> + Req1 = cowboy_req:reply(405, #{ + <<"content-type">> => <<"text/plain">> + }, <<"Method Not Allowed">>, Req0), + {ok, Req1, State}. + +serve_static_file(Path, Req0, State) -> + StaticDir = jchat_config:static_files_dir(), + + % Convert path to filename + Filename = case Path of + <<"/">> -> "index.html"; + <<"/", Rest/binary>> -> binary_to_list(Rest); + _ -> binary_to_list(Path) + end, + + FilePath = filename:join(StaticDir, Filename), + + case file:read_file(FilePath) of + {ok, Content} -> + ContentType = get_content_type(Filename), + Req1 = cowboy_req:reply(200, #{ + <<"content-type">> => ContentType, + <<"access-control-allow-origin">> => <<"*">>, + <<"cache-control">> => <<"public, max-age=3600">> + }, Content, Req0), + {ok, Req1, State}; + {error, enoent} -> + % If file not found and it's not an API request, serve index.html for SPA routing + case is_spa_route(Filename) of + true -> + IndexPath = filename:join(StaticDir, "index.html"), + case file:read_file(IndexPath) of + {ok, Content} -> + Req1 = cowboy_req:reply(200, #{ + <<"content-type">> => <<"text/html; charset=utf-8">>, + <<"access-control-allow-origin">> => <<"*">> + }, Content, Req0), + {ok, Req1, State}; + {error, _} -> + serve_404(Req0, State) + end; + false -> + serve_404(Req0, State) + end; + {error, _} -> + serve_500(Req0, State) + end. + +serve_404(Req0, State) -> + Req1 = cowboy_req:reply(404, #{ + <<"content-type">> => <<"text/plain">> + }, <<"Not Found">>, Req0), + {ok, Req1, State}. + +serve_500(Req0, State) -> + Req1 = cowboy_req:reply(500, #{ + <<"content-type">> => <<"text/plain">> + }, <<"Internal Server Error">>, Req0), + {ok, Req1, State}. + +get_content_type(Filename) -> + case filename:extension(Filename) of + ".html" -> <<"text/html; charset=utf-8">>; + ".css" -> <<"text/css; charset=utf-8">>; + ".js" -> <<"application/javascript; charset=utf-8">>; + ".json" -> <<"application/json; charset=utf-8">>; + ".png" -> <<"image/png">>; + ".jpg" -> <<"image/jpeg">>; + ".jpeg" -> <<"image/jpeg">>; + ".gif" -> <<"image/gif">>; + ".svg" -> <<"image/svg+xml">>; + ".ico" -> <<"image/x-icon">>; + ".woff" -> <<"font/woff">>; + ".woff2" -> <<"font/woff2">>; + ".ttf" -> <<"font/ttf">>; + ".eot" -> <<"application/vnd.ms-fontobject">>; + _ -> <<"application/octet-stream">> + end. + +is_spa_route(Filename) -> + % Don't serve index.html for actual file extensions + case filename:extension(Filename) of + "" -> true; % No extension, likely a SPA route + _ -> false % Has extension, likely a real file + end. diff --git a/server/src/jchat_http_upload.erl b/server/src/jchat_http_upload.erl new file mode 100644 index 0000000..6198a1f --- /dev/null +++ b/server/src/jchat_http_upload.erl @@ -0,0 +1,7 @@ +-module(jchat_http_upload). +-export([init/2]). + +init(Req0, State) -> + % TODO: Implement file upload + Req1 = cowboy_req:reply(501, #{}, <<"Upload not implemented">>, Req0), + {ok, Req1, State}. diff --git a/server/src/jchat_methods.erl b/server/src/jchat_methods.erl new file mode 100644 index 0000000..67d9f13 --- /dev/null +++ b/server/src/jchat_methods.erl @@ -0,0 +1,355 @@ +-module(jchat_methods). + +-export([handle_method/3]). + +-include("jchat.hrl"). + +%% Handle JMAP method calls +handle_method(<<"Core/echo">>, Args, _AccountId) -> + {ok, Args}; + +%% Conversation methods +handle_method(<<"Conversation/get">>, Args, AccountId) -> + handle_conversation_get(Args, AccountId); +handle_method(<<"Conversation/set">>, Args, AccountId) -> + handle_conversation_set(Args, AccountId); +handle_method(<<"Conversation/changes">>, Args, AccountId) -> + handle_conversation_changes(Args, AccountId); +handle_method(<<"Conversation/query">>, Args, AccountId) -> + handle_conversation_query(Args, AccountId); + +%% Message methods +handle_method(<<"Message/get">>, Args, AccountId) -> + handle_message_get(Args, AccountId); +handle_method(<<"Message/set">>, Args, AccountId) -> + handle_message_set(Args, AccountId); +handle_method(<<"Message/changes">>, Args, AccountId) -> + handle_message_changes(Args, AccountId); +handle_method(<<"Message/query">>, Args, AccountId) -> + handle_message_query(Args, AccountId); + +%% Participant methods +handle_method(<<"Participant/get">>, Args, AccountId) -> + handle_participant_get(Args, AccountId); +handle_method(<<"Participant/set">>, Args, AccountId) -> + handle_participant_set(Args, AccountId); + +%% Presence methods +handle_method(<<"Presence/get">>, Args, AccountId) -> + handle_presence_get(Args, AccountId); +handle_method(<<"Presence/set">>, Args, AccountId) -> + handle_presence_set(Args, AccountId); + +handle_method(Method, _Args, _AccountId) -> + {error, #{type => <<"unknownMethod">>, + description => <<"Unknown method: ", Method/binary>>}}. + +%% Conversation/get implementation +handle_conversation_get(#{<<"accountId">> := AccountId} = Args, AccountId) -> + Ids = maps:get(<<"ids">>, Args, null), + Properties = maps:get(<<"properties">>, Args, null), + + case get_conversations(Ids) of + {ok, Conversations} -> + List = [conversation_to_jmap(Conv, Properties) || Conv <- Conversations], + {ok, #{ + <<"accountId">> => AccountId, + <<"state">> => get_state(conversation), + <<"list">> => List, + <<"notFound">> => [] + }}; + {error, Error} -> + {error, Error} + end; +handle_conversation_get(_, _) -> + {error, account_not_found}. + +%% Conversation/set implementation +handle_conversation_set(#{<<"accountId">> := AccountId} = Args, AccountId) -> + Create = maps:get(<<"create">>, Args, #{}), + Update = maps:get(<<"update">>, Args, #{}), + Destroy = maps:get(<<"destroy">>, Args, []), + + {CreatedMap, NotCreated} = handle_conversation_creates(Create), + {UpdatedList, NotUpdated} = handle_conversation_updates(Update), + {DestroyedList, NotDestroyed} = handle_conversation_destroys(Destroy), + + {ok, #{ + <<"accountId">> => AccountId, + <<"oldState">> => get_state(conversation), + <<"newState">> => get_state(conversation), + <<"created">> => CreatedMap, + <<"updated">> => UpdatedList, + <<"destroyed">> => DestroyedList, + <<"notCreated">> => NotCreated, + <<"notUpdated">> => NotUpdated, + <<"notDestroyed">> => NotDestroyed + }}; +handle_conversation_set(_, _) -> + {error, account_not_found}. + +%% Message/get implementation +handle_message_get(#{<<"accountId">> := AccountId} = Args, AccountId) -> + Ids = maps:get(<<"ids">>, Args, null), + Properties = maps:get(<<"properties">>, Args, null), + + case get_messages(Ids) of + {ok, Messages} -> + List = [message_to_jmap(Msg, Properties) || Msg <- Messages], + {ok, #{ + <<"accountId">> => AccountId, + <<"state">> => get_state(message), + <<"list">> => List, + <<"notFound">> => [] + }}; + {error, Error} -> + {error, Error} + end; +handle_message_get(_, _) -> + {error, account_not_found}. + +%% Message/set implementation +handle_message_set(#{<<"accountId">> := AccountId} = Args, AccountId) -> + Create = maps:get(<<"create">>, Args, #{}), + Update = maps:get(<<"update">>, Args, #{}), + Destroy = maps:get(<<"destroy">>, Args, []), + + {CreatedMap, NotCreated} = handle_message_creates(Create), + {UpdatedList, NotUpdated} = handle_message_updates(Update), + {DestroyedList, NotDestroyed} = handle_message_destroys(Destroy), + + {ok, #{ + <<"accountId">> => AccountId, + <<"oldState">> => get_state(message), + <<"newState">> => get_state(message), + <<"created">> => CreatedMap, + <<"updated">> => UpdatedList, + <<"destroyed">> => DestroyedList, + <<"notCreated">> => NotCreated, + <<"notUpdated">> => NotUpdated, + <<"notDestroyed">> => NotDestroyed + }}; +handle_message_set(_, _) -> + {error, account_not_found}. + +handle_conversation_changes(_Args, _AccountId) -> + {ok, #{ + <<"accountId">> => <<"default">>, + <<"oldState">> => <<"0">>, + <<"newState">> => get_state(conversation), + <<"hasMoreChanges">> => false, + <<"created">> => [], + <<"updated">> => [], + <<"destroyed">> => [] + }}. + +handle_conversation_query(Args, AccountId) -> + case jchat_db:query_conversations(AccountId, Args) of + {ok, Conversations} -> + Ids = [Conv#conversation.id || Conv <- Conversations], + Total = length(Conversations), + {ok, #{ + <<"accountId">> => AccountId, + <<"queryState">> => get_state(conversation), + <<"canCalculateChanges">> => true, + <<"position">> => 0, + <<"ids">> => Ids, + <<"total">> => Total + }}; + {error, Error} -> + {error, Error} + end. + +handle_message_changes(_Args, _AccountId) -> + {ok, #{ + <<"accountId">> => <<"default">>, + <<"oldState">> => <<"0">>, + <<"newState">> => get_state(message), + <<"hasMoreChanges">> => false, + <<"created">> => [], + <<"updated">> => [], + <<"destroyed">> => [] + }}. + +handle_message_query(Args, AccountId) -> + Filter = maps:get(<<"filter">>, Args, #{}), + case jchat_db:query_messages(Filter, []) of + {ok, Messages} -> + Ids = [Msg#message.id || Msg <- Messages], + Total = length(Messages), + {ok, #{ + <<"accountId">> => AccountId, + <<"queryState">> => get_state(message), + <<"canCalculateChanges">> => true, + <<"position">> => 0, + <<"ids">> => Ids, + <<"total">> => Total + }}; + {error, Error} -> + {error, Error} + end. + +handle_participant_get(_Args, _AccountId) -> + {ok, #{ + <<"accountId">> => <<"default">>, + <<"state">> => get_state(participant), + <<"list">> => [], + <<"notFound">> => [] + }}. + +handle_participant_set(_Args, _AccountId) -> + {ok, #{ + <<"accountId">> => <<"default">>, + <<"oldState">> => get_state(participant), + <<"newState">> => get_state(participant), + <<"created">> => #{}, + <<"updated">> => [], + <<"destroyed">> => [], + <<"notCreated">> => #{}, + <<"notUpdated">> => [], + <<"notDestroyed">> => [] + }}. + +handle_presence_get(_Args, _AccountId) -> + {ok, #{ + <<"accountId">> => <<"default">>, + <<"state">> => get_state(presence), + <<"list">> => [], + <<"notFound">> => [] + }}. + +handle_presence_set(_Args, _AccountId) -> + {ok, #{ + <<"accountId">> => <<"default">>, + <<"oldState">> => get_state(presence), + <<"newState">> => get_state(presence), + <<"created">> => #{}, + <<"updated">> => [], + <<"destroyed">> => [], + <<"notCreated">> => #{}, + <<"notUpdated">> => [], + <<"notDestroyed">> => [] + }}. + +%% Helper functions +get_conversations(null) -> + % Return all conversations (simplified) + {ok, []}; +get_conversations(Ids) -> + Results = [jchat_db:get_conversation(Id) || Id <- Ids], + Conversations = [Conv || {ok, Conv} <- Results], + {ok, Conversations}. + +get_messages(null) -> + {ok, []}; +get_messages(Ids) -> + Results = [jchat_db:get_message(Id) || Id <- Ids], + Messages = [Msg || {ok, Msg} <- Results], + {ok, Messages}. + +handle_conversation_creates(Creates) -> + maps:fold(fun(CreationId, ConvData, {CreatedAcc, NotCreatedAcc}) -> + Id = jchat_utils:generate_id(), + case jchat_db:create_conversation(Id, ConvData) of + {ok, Conv} -> + JMAPConv = conversation_to_jmap(Conv, null), + {CreatedAcc#{CreationId => JMAPConv}, NotCreatedAcc}; + {error, Error} -> + {CreatedAcc, NotCreatedAcc#{CreationId => jchat_utils:format_error(Error)}} + end + end, {#{}, #{}}, Creates). + +handle_conversation_updates(Updates) -> + maps:fold(fun(Id, UpdateData, {UpdatedAcc, NotUpdatedAcc}) -> + case jchat_db:update_conversation(Id, UpdateData) of + {ok, Conv} -> + JMAPConv = conversation_to_jmap(Conv, null), + {[JMAPConv | UpdatedAcc], NotUpdatedAcc}; + {error, Error} -> + {UpdatedAcc, NotUpdatedAcc#{Id => jchat_utils:format_error(Error)}} + end + end, {[], #{}}, Updates). + +handle_conversation_destroys(Destroy) -> + % Simplified - would implement actual deletion + {Destroy, #{}}. + +handle_message_creates(Creates) -> + maps:fold(fun(CreationId, MsgData, {CreatedAcc, NotCreatedAcc}) -> + Id = jchat_utils:generate_id(), + case jchat_db:create_message(Id, MsgData) of + {ok, Msg} -> + JMAPMsg = message_to_jmap(Msg, null), + {CreatedAcc#{CreationId => JMAPMsg}, NotCreatedAcc}; + {error, Error} -> + {CreatedAcc, NotCreatedAcc#{CreationId => jchat_utils:format_error(Error)}} + end + end, {#{}, #{}}, Creates). + +handle_message_updates(Updates) -> + maps:fold(fun(Id, UpdateData, {UpdatedAcc, NotUpdatedAcc}) -> + case jchat_db:update_message(Id, UpdateData) of + {ok, Msg} -> + JMAPMsg = message_to_jmap(Msg, null), + {[JMAPMsg | UpdatedAcc], NotUpdatedAcc}; + {error, Error} -> + {UpdatedAcc, NotUpdatedAcc#{Id => jchat_utils:format_error(Error)}} + end + end, {[], #{}}, Updates). + +handle_message_destroys(Destroy) -> + % Simplified - would implement actual deletion + {Destroy, #{}}. + +conversation_to_jmap(#conversation{} = Conv, Properties) -> + Base = #{ + <<"id">> => Conv#conversation.id, + <<"title">> => Conv#conversation.title, + <<"description">> => Conv#conversation.description, + <<"createdAt">> => Conv#conversation.created_at, + <<"updatedAt">> => Conv#conversation.updated_at, + <<"isArchived">> => Conv#conversation.is_archived, + <<"isMuted">> => Conv#conversation.is_muted, + <<"participantIds">> => Conv#conversation.participant_ids, + <<"lastMessageId">> => Conv#conversation.last_message_id, + <<"lastMessageAt">> => Conv#conversation.last_message_at, + <<"unreadCount">> => Conv#conversation.unread_count, + <<"messageCount">> => Conv#conversation.message_count, + <<"metadata">> => Conv#conversation.metadata + }, + filter_properties(Base, Properties). + +message_to_jmap(#message{} = Msg, Properties) -> + Base = #{ + <<"id">> => Msg#message.id, + <<"conversationId">> => Msg#message.conversation_id, + <<"senderId">> => Msg#message.sender_id, + <<"sentAt">> => Msg#message.sent_at, + <<"receivedAt">> => Msg#message.received_at, + <<"editedAt">> => Msg#message.edited_at, + <<"body">> => Msg#message.body, + <<"bodyType">> => Msg#message.body_type, + <<"attachments">> => Msg#message.attachments, + <<"replyToMessageId">> => Msg#message.reply_to_message_id, + <<"isSystemMessage">> => Msg#message.is_system_message, + <<"isDeleted">> => Msg#message.is_deleted, + <<"reactions">> => Msg#message.reactions, + <<"deliveryStatus">> => Msg#message.delivery_status, + <<"readBy">> => Msg#message.read_by, + <<"metadata">> => Msg#message.metadata + }, + filter_properties(Base, Properties). + +filter_properties(Map, null) -> + Map; +filter_properties(Map, Properties) -> + maps:with(Properties, Map). + +get_state(Type) -> + Key = {<<"default">>, Type}, % {account_id, object_type} + case mnesia:dirty_read(state_counter, Key) of + [#state_counter{state = State}] -> + State; + [] -> + <<"0">> + end. diff --git a/server/src/jchat_presence.erl b/server/src/jchat_presence.erl new file mode 100644 index 0000000..42c23ee --- /dev/null +++ b/server/src/jchat_presence.erl @@ -0,0 +1,19 @@ +-module(jchat_presence). +-export([start_link/0, init/1, handle_call/3, handle_cast/2, handle_info/2]). + +-behaviour(gen_server). + +start_link() -> + gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). + +init([]) -> + {ok, #{}}. + +handle_call(_Request, _From, State) -> + {reply, ok, State}. + +handle_cast(_Request, State) -> + {noreply, State}. + +handle_info(_Info, State) -> + {noreply, State}. diff --git a/server/src/jchat_push.erl b/server/src/jchat_push.erl new file mode 100644 index 0000000..2ab8b9d --- /dev/null +++ b/server/src/jchat_push.erl @@ -0,0 +1,19 @@ +-module(jchat_push). +-export([start_link/0, init/1, handle_call/3, handle_cast/2, handle_info/2]). + +-behaviour(gen_server). + +start_link() -> + gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). + +init([]) -> + {ok, #{}}. + +handle_call(_Request, _From, State) -> + {reply, ok, State}. + +handle_cast(_Request, State) -> + {noreply, State}. + +handle_info(_Info, State) -> + {noreply, State}. diff --git a/server/src/jchat_sup.erl b/server/src/jchat_sup.erl new file mode 100644 index 0000000..cbb61ba --- /dev/null +++ b/server/src/jchat_sup.erl @@ -0,0 +1,50 @@ +-module(jchat_sup). +-behaviour(supervisor). + +-export([start_link/0, init/1]). + +start_link() -> + supervisor:start_link({local, ?MODULE}, ?MODULE, []). + +init([]) -> + % Initialize database + jchat_db:init(), + + % Child specifications + Children = [ + % HTTP server + #{ + id => jchat_http, + start => {jchat_http, start_link, []}, + restart => permanent, + shutdown => 5000, + type => worker, + modules => [jchat_http] + }, + % Push notification manager + #{ + id => jchat_push, + start => {jchat_push, start_link, []}, + restart => permanent, + shutdown => 5000, + type => worker, + modules => [jchat_push] + }, + % Presence manager + #{ + id => jchat_presence, + start => {jchat_presence, start_link, []}, + restart => permanent, + shutdown => 5000, + type => worker, + modules => [jchat_presence] + } + ], + + SupFlags = #{ + strategy => one_for_one, + intensity => 10, + period => 60 + }, + + {ok, {SupFlags, Children}}. diff --git a/server/src/jchat_utils.erl b/server/src/jchat_utils.erl new file mode 100644 index 0000000..3f94cfb --- /dev/null +++ b/server/src/jchat_utils.erl @@ -0,0 +1,163 @@ +-module(jchat_utils). + +-export([now_iso8601/0, + generate_id/0, + validate_id/1, + json_encode/1, + json_decode/1, + format_error/1, + validate_account_id/1, + extract_auth_token/1, + validate_method_call/1, + format_method_response/3, + format_error_response/3]). + +%% Generate ISO8601 timestamp +now_iso8601() -> + {{Year, Month, Day}, {Hour, Minute, Second}} = calendar:universal_time(), + iolist_to_binary(io_lib:format("~4..0w-~2..0w-~2..0wT~2..0w:~2..0w:~2..0wZ", + [Year, Month, Day, Hour, Minute, Second])). + +%% Generate a new ID (UUID v4) +generate_id() -> + <<U0:32, U1:16, _:4, U2:12, _:2, U3:62>> = crypto:strong_rand_bytes(16), + iolist_to_binary(io_lib:format("~8.16.0b-~4.16.0b-4~3.16.0b-~4.16.0b-~12.16.0b", + [U0, U1, U2, ((U3 bsr 60) band 16#03) bor 16#08, U3 band 16#0fffffffffff])). + +%% Validate ID format (URL-safe base64 characters) +validate_id(Id) when is_binary(Id) -> + Size = byte_size(Id), + case Size >= 1 andalso Size =< 255 of + true -> + validate_id_chars(Id); + false -> + false + end; +validate_id(_) -> + false. + +validate_id_chars(<<>>) -> + true; +validate_id_chars(<<C, Rest/binary>>) -> + case is_valid_id_char(C) of + true -> validate_id_chars(Rest); + false -> false + end. + +is_valid_id_char(C) -> + (C >= $A andalso C =< $Z) orelse + (C >= $a andalso C =< $z) orelse + (C >= $0 andalso C =< $9) orelse + C =:= $- orelse C =:= $_. + +%% JSON encoding/decoding +json_encode(Term) -> + jsx:encode(Term). + +json_decode(JSON) -> + try + {ok, jsx:decode(JSON, [return_maps])} + catch + _:_ -> {error, invalid_json} + end. + +%% Format errors for JMAP responses (RFC 8620 compliant) +format_error(not_found) -> + #{type => <<"notFound">>}; +format_error(invalid_arguments) -> + #{type => <<"invalidArguments">>}; +format_error(account_not_found) -> + #{type => <<"accountNotFound">>}; +format_error(forbidden) -> + #{type => <<"forbidden">>}; +format_error(invalid_result_reference) -> + #{type => <<"invalidResultReference">>}; +format_error(anchor_not_found) -> + #{type => <<"anchorNotFound">>}; +format_error(unsupported_sort) -> + #{type => <<"unsupportedSort">>}; +format_error(unsupported_filter) -> + #{type => <<"unsupportedFilter">>}; +format_error(cannot_calculate_changes) -> + #{type => <<"cannotCalculateChanges">>}; +format_error(too_large) -> + #{type => <<"tooLarge">>}; +format_error(too_many_changes) -> + #{type => <<"tooManyChanges">>}; +format_error(rate_limited) -> + #{type => <<"rateLimited">>}; +format_error(request_too_large) -> + #{type => <<"requestTooLarge">>}; +format_error(limit_exceeded) -> + #{type => <<"limitExceeded">>}; +format_error(state_mismatch) -> + #{type => <<"stateMismatch">>}; +format_error(will_destroy) -> + #{type => <<"willDestroy">>}; +format_error({invalid_arguments, Description}) -> + #{type => <<"invalidArguments">>, description => Description}; +format_error({not_found, Description}) -> + #{type => <<"notFound">>, description => Description}; +format_error({forbidden, Description}) -> + #{type => <<"forbidden">>, description => Description}; +format_error({server_fail, Description}) -> + #{type => <<"serverFail">>, description => Description}; +format_error(Error) -> + #{type => <<"serverFail">>, description => iolist_to_binary(io_lib:format("~p", [Error]))}. + +%% JMAP-specific utility functions + +%% Validate account ID format (as per JMAP spec) +validate_account_id(AccountId) when is_binary(AccountId) -> + validate_id(AccountId); +validate_account_id(_) -> + false. + +%% Extract Bearer token from Authorization header +extract_auth_token(undefined) -> + {error, no_auth_header}; +extract_auth_token(<<"Bearer ", Token/binary>>) -> + {ok, Token}; +extract_auth_token(_) -> + {error, invalid_auth_format}. + +%% Validate JMAP method call structure +validate_method_call([Method, Args, CallId]) + when is_binary(Method), is_map(Args), is_binary(CallId) -> + case validate_method_name(Method) of + true -> ok; + false -> {error, invalid_method_name} + end; +validate_method_call(_) -> + {error, invalid_method_call_structure}. + +validate_method_name(Method) -> + case binary:split(Method, <<"/">>) of + [Type, Operation] when byte_size(Type) > 0, byte_size(Operation) > 0 -> + validate_method_chars(Method); + _ -> + false + end. + +validate_method_chars(<<>>) -> + true; +validate_method_chars(<<C, Rest/binary>>) -> + case is_valid_method_char(C) of + true -> validate_method_chars(Rest); + false -> false + end. + +is_valid_method_char(C) -> + (C >= $A andalso C =< $Z) orelse + (C >= $a andalso C =< $z) orelse + (C >= $0 andalso C =< $9) orelse + C =:= $/ orelse C =:= $_. + +%% Format successful method response +format_method_response(Method, Result, CallId) -> + [Method, Result, CallId]. + +%% Format error method response +format_error_response(Error, CallId, Method) -> + ErrorMap = format_error(Error), + [<<"error">>, ErrorMap#{<<"description">> => <<"Error in method: ", Method/binary>>}, CallId]. |