diff options
author | Adam Kocoloski <adam@cloudant.com> | 2010-06-09 11:41:57 -0400 |
---|---|---|
committer | Adam Kocoloski <adam@cloudant.com> | 2010-06-09 11:41:57 -0400 |
commit | 8b187422a888a94b8f1d48eee52f100510a83141 (patch) | |
tree | 01556f7f26a4732a3c8bcc63a0127a195f00b61f /src/chttpd.erl |
new app for dedicated cluster HTTP interface
Diffstat (limited to 'src/chttpd.erl')
-rw-r--r-- | src/chttpd.erl | 602 |
1 files changed, 602 insertions, 0 deletions
diff --git a/src/chttpd.erl b/src/chttpd.erl new file mode 100644 index 00000000..a062f89a --- /dev/null +++ b/src/chttpd.erl @@ -0,0 +1,602 @@ +% Licensed under the Apache License, Version 2.0 (the "License"); you may not +% use this file except in compliance with the License. You may obtain a copy of +% the License at +% +% http://www.apache.org/licenses/LICENSE-2.0 +% +% Unless required by applicable law or agreed to in writing, software +% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +% License for the specific language governing permissions and limitations under +% the License. + +-module(chttpd). +-include("chttpd.hrl"). + +-export([start_link/0, stop/0, handle_request/5, config_change/2]). + +-export([header_value/2,header_value/3,qs_value/2,qs_value/3,qs/1,path/1,absolute_uri/2,body_length/1]). +-export([verify_is_server_admin/1,unquote/1,quote/1,recv/2,recv_chunked/4,error_info/1]). +-export([parse_form/1,json_body/1,json_body_obj/1,body/1,doc_etag/1, make_etag/1, etag_respond/3]). +-export([primary_header_value/2,partition/1,serve_file/3, server_header/0]). +-export([start_chunked_response/3,send_chunk/2]). +-export([start_response_length/4, send/2]). +-export([start_json_response/2, start_json_response/3, end_json_response/1]). +-export([send_response/4,send_method_not_allowed/2,send_error/4, send_redirect/2,send_chunked_error/2]). +-export([send_json/2,send_json/3,send_json/4]). + +start_link() -> + BindAddress = couch_config:get("chttpd", "bind_address", any), + Port = couch_config:get("chttpd", "port", "5984"), + Backlog = list_to_integer(couch_config:get("chttpd", "backlog", "128")), + + Default = fun chttpd_db:handle_request/1, + + UrlHandlers = [ + {<<"/">>, fun chttpd_misc:handle_welcome_req/1}, + {<<"favicon.ico">>, fun chttpd_misc:handle_favicon_req/1}, + {<<"_utils">>, fun chttpd_misc:handle_utils_dir_req/1}, + {<<"_all_dbs">>, fun chttpd_misc:handle_all_dbs_req/1}, + {<<"_active_tasks">>, fun chttpd_misc:handle_task_status_req/1}, + {<<"_config">>, fun chttpd_misc:handle_config_req/1}, + {<<"_replicate">>, fun chttpd_misc:handle_replicate_req/1}, + {<<"_uuids">>, fun chttpd_misc:handle_uuids_req/1}, + {<<"_log">>, fun chttpd_misc:handle_log_req/1}, + {<<"_sleep">>, fun chttpd_misc:handle_sleep_req/1}, + {<<"_session">>, fun chttpd_auth:handle_session_req/1}, + {<<"_user">>, fun chttpd_auth:handle_user_req/1}, + {<<"_oauth">>, fun chttpd_oauth:handle_oauth_req/1}, + {<<"_stats">>, fun chttpd_stats:handle_stats_req/1}, + {<<"_restart">>, fun showroom_http:handle_restart_req/1}, + {<<"_cloudant">>, fun showroom_httpd_admin:handle_cloudant_req/1} + ], + + DbHandlers = [ + {<<"_view_cleanup">>, fun chttpd_view:handle_view_cleanup_req/2}, + {<<"_compact">>, fun chttpd_db:handle_compact_req/2}, + {<<"_design">>, fun chttpd_db:handle_design_req/2}, + {<<"_view">>, fun chttpd_db:handle_db_view_req/2}, + {<<"_temp_view">>, fun chttpd_db:handle_temp_view_req/2}, + {<<"_changes">>, fun chttpd_db:handle_changes_req/2} + ], + + DesignHandlers = [ + {<<"_view">>, fun chttpd_view:handle_view_req/2}, + {<<"_show">>, fun chttpd_show:handle_doc_show_req/2}, + {<<"_list">>, fun chttpd_show:handle_view_list_req/2}, + {<<"_update">>, fun chttpd_show:handle_doc_update_req/2}, + {<<"_info">>, fun chttpd_db:handle_design_info_req/2} + ], + + Loop = fun(Req)-> ?MODULE:handle_request(Req, Default, UrlHandlers, + DbHandlers, DesignHandlers) end, + + {ok, Pid} = case mochiweb_http:start([ + {loop, Loop}, + {name, ?MODULE}, + {ip, BindAddress}, + {port, Port}, + {backlog, Backlog} + ]) of + {ok, MochiPid} -> {ok, MochiPid}; + {error, Reason} -> + io:format("Failure to start Mochiweb: ~s~n",[Reason]), + throw({error, Reason}) + end, + + ok = couch_config:register(fun ?MODULE:config_change/2, Pid), + + {ok, Pid}. + +config_change("chttpd", "bind_address") -> + ?MODULE:stop(); +config_change("chttpd", "port") -> + ?MODULE:stop(); +config_change("chttpd", "backlog") -> + ?MODULE:stop(). + +stop() -> + mochiweb_http:stop(?MODULE). + +handle_request(MochiReq, DefaultFun, + UrlHandlers, DbUrlHandlers, DesignUrlHandlers) -> + Begin = now(), + + AuthenticationFuns = [ + fun chttpd_auth:cookie_authentication_handler/1, + fun chttpd_auth:default_authentication_handler/1 + ], + + % for the path, use the raw path with the query string and fragment + % removed, but URL quoting left intact + RawUri = MochiReq:get(raw_path), + Customer = cloudant_util:customer_name(MochiReq:get_header_value("X-Cloudant-User"), MochiReq:get_header_value("Host")), + Path = ?COUCH:db_path(RawUri, Customer), + + HandlerKey = + case mochiweb_util:partition(Path, "/") of + {"", "", ""} -> + <<"/">>; % Special case the root url handler + {FirstPart, _, _} -> + list_to_binary(FirstPart) + end, + LogForClosedSocket = io_lib:format("mochiweb_recv_error for ~s - ~p ~s", [ + MochiReq:get(peer), + MochiReq:get(method), + RawUri + ]), + + Method1 = + case MochiReq:get(method) of + % already an atom + Meth when is_atom(Meth) -> Meth; + + % Non standard HTTP verbs aren't atoms (COPY, MOVE etc) so convert when + % possible (if any module references the atom, then it's existing). + Meth -> couch_util:to_existing_atom(Meth) + end, + increment_method_stats(Method1), + % alias HEAD to GET as mochiweb takes care of stripping the body + Method = case Method1 of + 'HEAD' -> 'GET'; + Other -> Other + end, + + HttpReq = #httpd{ + mochi_req = MochiReq, + method = Method, + path_parts = [list_to_binary(chttpd:unquote(Part)) + || Part <- string:tokens(Path, "/")], + db_url_handlers = DbUrlHandlers, + design_url_handlers = DesignUrlHandlers + }, + + HandlerFun = couch_util:get_value(HandlerKey, UrlHandlers, DefaultFun), + {ok, Resp} = + try + erase(cookie_auth_failed), + case authenticate_request(HttpReq, AuthenticationFuns) of + #httpd{} = Req -> + HandlerFun(cloudant_auth:authorize_request(Req)); + Response -> + Response + end + catch + throw:{http_head_abort, Resp0} -> + {ok, Resp0}; + throw:{invalid_json, S} -> + ?LOG_ERROR("attempted upload of invalid JSON ~s", [S]), + send_error(HttpReq, {bad_request, "invalid UTF-8 JSON"}); + exit:{mochiweb_recv_error, E} -> + ?LOG_INFO("mochiweb_recv_error: ~p", [E]), + showroom_log:message(notice, LogForClosedSocket, []), + exit(normal); + throw:Error -> + % ?LOG_DEBUG("Minor error in HTTP request: ~p",[Error]), + % ?LOG_DEBUG("Stacktrace: ~p",[erlang:get_stacktrace()]), + send_error(HttpReq, Error); + error:badarg -> + ?LOG_ERROR("Badarg error in HTTP request",[]), + ?LOG_INFO("Stacktrace: ~p",[erlang:get_stacktrace()]), + send_error(HttpReq, badarg); + error:function_clause -> + ?LOG_ERROR("function_clause error in HTTP request",[]), + ?LOG_INFO("Stacktrace: ~p",[erlang:get_stacktrace()]), + send_error(HttpReq, function_clause); + Tag:Error -> + ?LOG_ERROR("Uncaught error in HTTP request: ~p",[{Tag, Error}]), + ?LOG_INFO("Stacktrace: ~p",[erlang:get_stacktrace()]), + send_error(HttpReq, Error) + end, + + RequestTime = round(timer:now_diff(now(), Begin)/1000), + showroom_log:message(notice, "~s ~s ~s ~s ~B ~B", [ + MochiReq:get(peer), + MochiReq:get_header_value("Host"), + atom_to_list(Method1), + RawUri, + Resp:get(code), + RequestTime + ]), + couch_stats_collector:record({couchdb, request_time}, RequestTime), + couch_stats_collector:increment({httpd, requests}), + {ok, Resp}. + +% Try authentication handlers in order until one returns a result +authenticate_request(#httpd{user_ctx=#user_ctx{}} = Req, _AuthFuns) -> + Req; +authenticate_request(#httpd{} = Req, [AuthFun|Rest]) -> + authenticate_request(AuthFun(Req), Rest); +authenticate_request(#httpd{} = Req, []) -> + case couch_config:get("chttpd_auth", "require_valid_user", "false") of + "true" -> + throw({unauthorized, <<"Authentication required.">>}); + "false" -> + case couch_config:get("admins") of + [] -> + Ctx = #user_ctx{roles=[<<"_reader">>, <<"_writer">>, <<"_admin">>]}, + Req#httpd{user_ctx = Ctx}; + _ -> + Req#httpd{user_ctx=#user_ctx{}} + end + end; +authenticate_request(Response, _AuthFuns) -> + Response. + +increment_method_stats(Method) -> + couch_stats_collector:increment({httpd_request_methods, Method}). + + +% Utilities + +partition(Path) -> + mochiweb_util:partition(Path, "/"). + +header_value(#httpd{mochi_req=MochiReq}, Key) -> + MochiReq:get_header_value(Key). + +header_value(#httpd{mochi_req=MochiReq}, Key, Default) -> + case MochiReq:get_header_value(Key) of + undefined -> Default; + Value -> Value + end. + +primary_header_value(#httpd{mochi_req=MochiReq}, Key) -> + MochiReq:get_primary_header_value(Key). + +serve_file(#httpd{mochi_req=MochiReq}=Req, RelativePath, DocumentRoot) -> + {ok, MochiReq:serve_file(RelativePath, DocumentRoot, + server_header() ++ chttpd_auth:cookie_auth_header(Req, []))}. + +qs_value(Req, Key) -> + qs_value(Req, Key, undefined). + +qs_value(Req, Key, Default) -> + couch_util:get_value(Key, qs(Req), Default). + +qs(#httpd{mochi_req=MochiReq}) -> + MochiReq:parse_qs(). + +path(#httpd{mochi_req=MochiReq}) -> + MochiReq:get(path). + +absolute_uri(#httpd{mochi_req=MochiReq}, Path) -> + XHost = couch_config:get("httpd", "x_forwarded_host", "X-Forwarded-Host"), + Host = case MochiReq:get_header_value(XHost) of + undefined -> + case MochiReq:get_header_value("Host") of + undefined -> + {ok, {Address, Port}} = inet:sockname(MochiReq:get(socket)), + inet_parse:ntoa(Address) ++ ":" ++ integer_to_list(Port); + Value1 -> + Value1 + end; + Value -> Value + end, + XSsl = couch_config:get("httpd", "x_forwarded_ssl", "X-Forwarded-Ssl"), + Scheme = case MochiReq:get_header_value(XSsl) of + "on" -> "https"; + _ -> + XProto = couch_config:get("httpd", "x_forwarded_proto", "X-Forwarded-Proto"), + case MochiReq:get_header_value(XProto) of + % Restrict to "https" and "http" schemes only + "https" -> "https"; + _ -> "http" + end + end, + Customer = cloudant_util:customer_name(MochiReq:get_header_value("X-Cloudant-User"), + Host), + CustomerRegex = ["^/", Customer, "[/%2F]+"], + NewPath = re:replace(Path, CustomerRegex, "/"), + Scheme ++ "://" ++ Host ++ NewPath. + +unquote(UrlEncodedString) -> + mochiweb_util:unquote(UrlEncodedString). + +quote(UrlDecodedString) -> + mochiweb_util:quote_plus(UrlDecodedString). + +parse_form(#httpd{mochi_req=MochiReq}) -> + mochiweb_multipart:parse_form(MochiReq). + +recv(#httpd{mochi_req=MochiReq}, Len) -> + MochiReq:recv(Len). + +recv_chunked(#httpd{mochi_req=MochiReq}, MaxChunkSize, ChunkFun, InitState) -> + % Fun is called once with each chunk + % Fun({Length, Binary}, State) + % called with Length == 0 on the last time. + MochiReq:stream_body(MaxChunkSize, ChunkFun, InitState). + +body_length(Req) -> + case header_value(Req, "Transfer-Encoding") of + undefined -> + case header_value(Req, "Content-Length") of + undefined -> undefined; + Length -> list_to_integer(Length) + end; + "chunked" -> chunked; + Unknown -> {unknown_transfer_encoding, Unknown} + end. + +body(#httpd{mochi_req=MochiReq, req_body=ReqBody}) -> + case ReqBody of + undefined -> + % Maximum size of document PUT request body (4GB) + MaxSize = list_to_integer( + couch_config:get("couchdb", "max_document_size", "4294967296")), + MochiReq:recv_body(MaxSize); + _Else -> + ReqBody + end. + +json_body(Httpd) -> + ?JSON_DECODE(body(Httpd)). + +json_body_obj(Httpd) -> + case json_body(Httpd) of + {Props} -> {Props}; + _Else -> + throw({bad_request, "Request body must be a JSON object"}) + end. + + +doc_etag(#doc{revs={Start, [DiskRev|_]}}) -> + "\"" ++ ?b2l(couch_doc:rev_to_str({Start, DiskRev})) ++ "\"". + +make_etag(Term) -> + <<SigInt:128/integer>> = erlang:md5(term_to_binary(Term)), + list_to_binary("\"" ++ lists:flatten(io_lib:format("~.36B",[SigInt])) ++ "\""). + +etag_match(Req, CurrentEtag) when is_binary(CurrentEtag) -> + etag_match(Req, binary_to_list(CurrentEtag)); + +etag_match(Req, CurrentEtag) -> + EtagsToMatch = string:tokens( + chttpd:header_value(Req, "If-None-Match", ""), ", "), + lists:member(CurrentEtag, EtagsToMatch). + +etag_respond(Req, CurrentEtag, RespFun) -> + case etag_match(Req, CurrentEtag) of + true -> + % the client has this in their cache. + chttpd:send_response(Req, 304, [{"Etag", CurrentEtag}], <<>>); + false -> + % Run the function. + RespFun() + end. + +verify_is_server_admin(#httpd{user_ctx=#user_ctx{roles=Roles}}) -> + case lists:member(<<"_admin">>, Roles) of + true -> ok; + false -> throw({unauthorized, <<"You are not a server admin.">>}) + end. + +start_response_length(#httpd{mochi_req=MochiReq}=Req, Code, Headers, Length) -> + couch_stats_collector:increment({httpd_status_codes, Code}), + Resp = MochiReq:start_response_length({Code, Headers ++ server_header() ++ chttpd_auth:cookie_auth_header(Req, Headers), Length}), + case MochiReq:get(method) of + 'HEAD' -> throw({http_head_abort, Resp}); + _ -> ok + end, + {ok, Resp}. + +send(Resp, Data) -> + Resp:send(Data), + {ok, Resp}. + +start_chunked_response(#httpd{mochi_req=MochiReq}=Req, Code, Headers) -> + couch_stats_collector:increment({httpd_status_codes, Code}), + Resp = MochiReq:respond({Code, Headers ++ server_header() ++ chttpd_auth:cookie_auth_header(Req, Headers), chunked}), + case MochiReq:get(method) of + 'HEAD' -> throw({http_head_abort, Resp}); + _ -> ok + end, + {ok, Resp}. + +send_chunk(Resp, Data) -> + Resp:write_chunk(Data), + {ok, Resp}. + +send_response(#httpd{mochi_req=MochiReq}=Req, Code, Headers, Body) -> + couch_stats_collector:increment({httpd_status_codes, Code}), + if Code >= 400 -> + ?LOG_DEBUG("httpd ~p error response:~n ~s", [Code, Body]); + true -> ok + end, + {ok, MochiReq:respond({Code, Headers ++ server_header() ++ chttpd_auth:cookie_auth_header(Req, Headers), Body})}. + +send_method_not_allowed(Req, Methods) -> + send_error(Req, 405, [{"Allow", Methods}], <<"method_not_allowed">>, ?l2b("Only " ++ Methods ++ " allowed")). + +send_json(Req, Value) -> + send_json(Req, 200, Value). + +send_json(Req, Code, Value) -> + send_json(Req, Code, [], Value). + +send_json(Req, Code, Headers, Value) -> + DefaultHeaders = [ + {"Content-Type", negotiate_content_type(Req)}, + {"Cache-Control", "must-revalidate"} + ], + Body = list_to_binary( + [start_jsonp(Req), ?JSON_ENCODE(Value), end_jsonp(), $\n] + ), + send_response(Req, Code, DefaultHeaders ++ Headers, Body). + +start_json_response(Req, Code) -> + start_json_response(Req, Code, []). + +start_json_response(Req, Code, Headers) -> + DefaultHeaders = [ + {"Content-Type", negotiate_content_type(Req)}, + {"Cache-Control", "must-revalidate"} + ], + start_jsonp(Req), % Validate before starting chunked. + %start_chunked_response(Req, Code, DefaultHeaders ++ Headers). + {ok, Resp} = start_chunked_response(Req, Code, DefaultHeaders ++ Headers), + case start_jsonp(Req) of + [] -> ok; + Start -> send_chunk(Resp, Start) + end, + {ok, Resp}. + +end_json_response(Resp) -> + send_chunk(Resp, end_jsonp() ++ [$\r,$\n]), + %send_chunk(Resp, [$\n]), + send_chunk(Resp, []). + +start_jsonp(Req) -> + case get(jsonp) of + undefined -> put(jsonp, qs_value(Req, "callback", no_jsonp)); + _ -> ok + end, + case get(jsonp) of + no_jsonp -> []; + [] -> []; + CallBack -> + try + validate_callback(CallBack), + CallBack ++ "(" + catch + Error -> + put(jsonp, no_jsonp), + throw(Error) + end + end. + +end_jsonp() -> + Resp = case get(jsonp) of + no_jsonp -> []; + [] -> []; + _ -> ");" + end, + put(jsonp, undefined), + Resp. + +validate_callback(CallBack) when is_binary(CallBack) -> + validate_callback(binary_to_list(CallBack)); +validate_callback([]) -> + ok; +validate_callback([Char | Rest]) -> + case Char of + _ when Char >= $a andalso Char =< $z -> ok; + _ when Char >= $A andalso Char =< $Z -> ok; + _ when Char >= $0 andalso Char =< $9 -> ok; + _ when Char == $. -> ok; + _ when Char == $_ -> ok; + _ when Char == $[ -> ok; + _ when Char == $] -> ok; + _ -> + throw({bad_request, invalid_callback}) + end, + validate_callback(Rest). + + +error_info({Error, Reason}) when is_list(Reason) -> + error_info({Error, ?l2b(Reason)}); +error_info(bad_request) -> + {400, <<"bad_request">>, <<>>}; +error_info({bad_request, Reason}) -> + {400, <<"bad_request">>, Reason}; +error_info({query_parse_error, Reason}) -> + {400, <<"query_parse_error">>, Reason}; +error_info(not_found) -> + {404, <<"not_found">>, <<"missing">>}; +error_info({not_found, Reason}) -> + {404, <<"not_found">>, Reason}; +error_info({not_acceptable, Reason}) -> + {406, <<"not_acceptable">>, Reason}; +error_info(conflict) -> + {409, <<"conflict">>, <<"Document update conflict.">>}; +error_info({forbidden, Msg}) -> + {403, <<"forbidden">>, Msg}; +error_info({credentials_expired, Msg}) -> + {403, <<"credentials_expired">>, Msg}; +error_info({unauthorized, Msg}) -> + {401, <<"unauthorized">>, Msg}; +error_info(file_exists) -> + {412, <<"file_exists">>, <<"The database could not be " + "created, the file already exists.">>}; +error_info({r_quorum_not_met, Reason}) -> + {412, <<"read_quorum_not_met">>, Reason}; +error_info({w_quorum_not_met, Reason}) -> + {500, <<"write_quorum_not_met">>, Reason}; +error_info({bad_ctype, Reason}) -> + {415, <<"bad_content_type">>, Reason}; +error_info({error, illegal_database_name}) -> + {400, <<"illegal_database_name">>, <<"Only lowercase characters (a-z), " + "digits (0-9), and any of the characters _, $, (, ), +, -, and / " + "are allowed">>}; +error_info(not_implemented) -> + {501, <<"not_implemented">>, <<"this feature is not yet implemented">>}; +error_info({Error, Reason}) -> + {500, couch_util:to_binary(Error), couch_util:to_binary(Reason)}; +error_info(Error) -> + {500, <<"unknown_error">>, couch_util:to_binary(Error)}. + +send_error(_Req, {already_sent, Resp, _Error}) -> + {ok, Resp}; + +send_error(#httpd{mochi_req=MochiReq}=Req, Error) -> + {Code, ErrorStr, ReasonStr} = error_info(Error), + Headers = if Code == 401 -> + case MochiReq:get_header_value("X-CouchDB-WWW-Authenticate") of + undefined -> + case couch_config:get("httpd", "WWW-Authenticate", nil) of + nil -> + []; + Type -> + [{"WWW-Authenticate", Type}] + end; + Type -> + [{"WWW-Authenticate", Type}] + end; + true -> + [] + end, + send_error(Req, Code, Headers, ErrorStr, ReasonStr). + +send_error(Req, Code, ErrorStr, ReasonStr) -> + send_error(Req, Code, [], ErrorStr, ReasonStr). + +send_error(Req, Code, Headers, ErrorStr, ReasonStr) -> + send_json(Req, Code, Headers, + {[{<<"error">>, ErrorStr}, + {<<"reason">>, ReasonStr}]}). + +% give the option for list functions to output html or other raw errors +send_chunked_error(Resp, {_Error, {[{<<"body">>, Reason}]}}) -> + send_chunk(Resp, Reason), + send_chunk(Resp, []); + +send_chunked_error(Resp, Error) -> + {Code, ErrorStr, ReasonStr} = error_info(Error), + JsonError = {[{<<"code">>, Code}, + {<<"error">>, ErrorStr}, + {<<"reason">>, ReasonStr}]}, + send_chunk(Resp, ?l2b([$\n,?JSON_ENCODE(JsonError),$\n])), + send_chunk(Resp, []). + +send_redirect(Req, Path) -> + Headers = [{"Location", chttpd:absolute_uri(Req, Path)}], + send_response(Req, 301, Headers, <<>>). + +negotiate_content_type(#httpd{mochi_req=MochiReq}) -> + %% Determine the appropriate Content-Type header for a JSON response + %% depending on the Accept header in the request. A request that explicitly + %% lists the correct JSON MIME type will get that type, otherwise the + %% response will have the generic MIME type "text/plain" + AcceptedTypes = case MochiReq:get_header_value("Accept") of + undefined -> []; + AcceptHeader -> string:tokens(AcceptHeader, ", ") + end, + case lists:member("application/json", AcceptedTypes) of + true -> "application/json"; + false -> "text/plain;charset=utf-8" + end. + +server_header() -> + OTPVersion = erlang:system_info(otp_release), + [{"Server", "CouchDB/0.11.0 (Erlang OTP/" ++ OTPVersion ++ ")"}]. |