aboutsummaryrefslogtreecommitdiff
path: root/server/src
diff options
context:
space:
mode:
authorCalvin Morrison <calvin@pobox.com>2025-09-03 21:15:36 -0400
committerCalvin Morrison <calvin@pobox.com>2025-09-03 21:15:36 -0400
commit49fa5aa2a127bdf8924d02bf77e5086b39c7a447 (patch)
tree61d86a7705dacc9fddccc29fa79d075d83ab8059 /server/src
i vibe coded itHEADmaster
Diffstat (limited to 'server/src')
-rw-r--r--server/src/jchat.app.src21
-rw-r--r--server/src/jchat_app.erl10
-rw-r--r--server/src/jchat_auth.erl433
-rw-r--r--server/src/jchat_config.erl46
-rw-r--r--server/src/jchat_db.erl390
-rw-r--r--server/src/jchat_dev.erl88
-rw-r--r--server/src/jchat_http.erl265
-rw-r--r--server/src/jchat_http_404.erl14
-rw-r--r--server/src/jchat_http_auth.erl155
-rw-r--r--server/src/jchat_http_download.erl7
-rw-r--r--server/src/jchat_http_eventsource.erl7
-rw-r--r--server/src/jchat_http_health.erl21
-rw-r--r--server/src/jchat_http_redirect.erl17
-rw-r--r--server/src/jchat_http_static.erl110
-rw-r--r--server/src/jchat_http_upload.erl7
-rw-r--r--server/src/jchat_methods.erl355
-rw-r--r--server/src/jchat_presence.erl19
-rw-r--r--server/src/jchat_push.erl19
-rw-r--r--server/src/jchat_sup.erl50
-rw-r--r--server/src/jchat_utils.erl163
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].