From cd0e9c9b6384e4c9200d10088a13164ce4229ea6 Mon Sep 17 00:00:00 2001 From: John Christopher Anderson Date: Thu, 7 Jan 2010 20:02:46 +0000 Subject: merge account branch to trunk git-svn-id: https://svn.apache.org/repos/asf/couchdb/trunk@896989 13f79535-47bb-0310-9956-ffa450edef68 --- src/couchdb/couch_httpd_auth.erl | 439 ++++++++++++++++----------------------- 1 file changed, 174 insertions(+), 265 deletions(-) (limited to 'src/couchdb/couch_httpd_auth.erl') diff --git a/src/couchdb/couch_httpd_auth.erl b/src/couchdb/couch_httpd_auth.erl index b244e16e..554886ca 100644 --- a/src/couchdb/couch_httpd_auth.erl +++ b/src/couchdb/couch_httpd_auth.erl @@ -18,7 +18,6 @@ -export([null_authentication_handler/1]). -export([cookie_auth_header/2]). -export([handle_session_req/1]). --export([handle_user_req/1]). -export([ensure_users_db_exists/1, get_user/1]). -import(couch_httpd, [header_value/2, send_json/2,send_json/4, send_method_not_allowed/2]). @@ -49,6 +48,9 @@ basic_username_pw(Req) -> case AuthorizationHeader of "Basic " ++ Base64Value -> case string:tokens(?b2l(couch_util:decodeBase64(Base64Value)),":") of + ["_", "_"] -> + % special name and pass to be logged out + nil; [User, Pass] -> {User, Pass}; [User] -> @@ -63,11 +65,22 @@ basic_username_pw(Req) -> default_authentication_handler(Req) -> case basic_username_pw(Req) of {User, Pass} -> - case couch_server:is_admin(User, Pass) of - true -> - Req#httpd{user_ctx=#user_ctx{name=?l2b(User), roles=[<<"_admin">>]}}; - false -> - throw({unauthorized, <<"Name or password is incorrect.">>}) + case get_user(?l2b(User)) of + nil -> + throw({unauthorized, <<"Name or password is incorrect.">>}); + UserProps -> + UserSalt = proplists:get_value(<<"salt">>, UserProps, <<>>), + PasswordHash = hash_password(?l2b(Pass), UserSalt), + case proplists:get_value(<<"password_sha">>, UserProps, nil) of + ExpectedHash when ExpectedHash == PasswordHash -> + Req#httpd{user_ctx=#user_ctx{ + name=?l2b(User), + roles=proplists:get_value(<<"roles">>, UserProps, []), + user_doc={UserProps} + }}; + _Else -> + throw({unauthorized, <<"Name or password is incorrect.">>}) + end end; nil -> case couch_server:has_admins() of @@ -86,138 +99,161 @@ default_authentication_handler(Req) -> null_authentication_handler(Req) -> Req#httpd{user_ctx=#user_ctx{roles=[<<"_admin">>]}}. -% Cookie auth handler using per-node user db -cookie_authentication_handler(Req) -> - case cookie_auth_user(Req) of - % Fall back to default authentication handler - nil -> default_authentication_handler(Req); - Req2 -> Req2 - end. - -% Cookie auth handler using per-db user db -% cookie_authentication_handler(#httpd{path_parts=Path}=Req) -> -% case Path of -% [DbName|_] -> -% case cookie_auth_user(Req, DbName) of -% nil -> default_authentication_handler(Req); -% Req2 -> Req2 -% end; -% _Else -> -% % Fall back to default authentication handler -% default_authentication_handler(Req) -% end. - % maybe we can use hovercraft to simplify running this view query +% rename to get_user_from_users_db get_user(UserName) -> - % In the future this will be pluggable. For now we check the .ini first, - % then fall back to querying the db. case couch_config:get("admins", ?b2l(UserName)) of "-hashed-" ++ HashedPwdAndSalt -> + % the username is an admin, now check to see if there is a user doc + % which has a matching username, salt, and password_sha [HashedPwd, Salt] = string:tokens(HashedPwdAndSalt, ","), - [{<<"roles">>, [<<"_admin">>]}, - {<<"salt">>, ?l2b(Salt)}, - {<<"password_sha">>, ?l2b(HashedPwd)}]; - _ -> - DesignId = <<"_design/_auth">>, - ViewName = <<"users">>, - % if the design doc or the view doesn't exist, then make it - DbName = couch_config:get("couch_httpd_auth", "authentication_db"), - {ok, Db} = ensure_users_db_exists(?l2b(DbName)), - - ensure_users_view_exists(Db, DesignId, ViewName), + case get_user_props_from_db(UserName) of + nil -> + [{<<"roles">>, [<<"_admin">>]}, + {<<"salt">>, ?l2b(Salt)}, + {<<"password_sha">>, ?l2b(HashedPwd)}]; + UserProps when is_list(UserProps) -> + DocRoles = proplists:get_value(<<"roles">>, UserProps), + [{<<"roles">>, [<<"_admin">> | DocRoles]}, + {<<"salt">>, ?l2b(Salt)}, + {<<"password_sha">>, ?l2b(HashedPwd)}, + {<<"user_doc">>, {UserProps}}] + end; + Else -> + get_user_props_from_db(UserName) + end. - case (catch couch_view:get_map_view(Db, DesignId, ViewName, nil)) of - {ok, View, _Group} -> - FoldFun = fun({_, Value}, _, {_}) -> {stop, Value} end, - {ok, _, {Result}} = couch_view:fold(View, FoldFun, {nil}, - [{start_key, {UserName, ?MIN_STR}},{end_key, {UserName, ?MAX_STR}}]), - Result; - {not_found, _Reason} -> - nil - % case (catch couch_view:get_reduce_view(Db, DesignId, ViewName, nil)) of - % {ok, _ReduceView, _Group} -> - % not_implemented; - % {not_found, _Reason} -> - % nil - % end - end +get_user_props_from_db(UserName) -> + DbName = couch_config:get("couch_httpd_auth", "authentication_db"), + {ok, Db} = ensure_users_db_exists(?l2b(DbName)), + DocId = <<"org.couchdb.user:", UserName/binary>>, + try couch_httpd_db:couch_doc_open(Db, DocId, nil, []) of + #doc{}=Doc -> + {DocProps} = couch_query_servers:json_doc(Doc), + DocProps + catch + throw:Throw -> + nil end. - + +% this should handle creating the ddoc ensure_users_db_exists(DbName) -> case couch_db:open(DbName, [{user_ctx, #user_ctx{roles=[<<"_admin">>]}}]) of {ok, Db} -> + ensure_auth_ddoc_exists(Db, <<"_design/_auth">>), {ok, Db}; _Error -> - ?LOG_ERROR("Create the db ~p", [DbName]), {ok, Db} = couch_db:create(DbName, [{user_ctx, #user_ctx{roles=[<<"_admin">>]}}]), - ?LOG_ERROR("Created the db ~p", [DbName]), + ensure_auth_ddoc_exists(Db, <<"_design/_auth">>), {ok, Db} end. -ensure_users_view_exists(Db, DDocId, VName) -> +ensure_auth_ddoc_exists(Db, DDocId) -> try couch_httpd_db:couch_doc_open(Db, DDocId, nil, []) of _Foo -> ok catch _:Error -> - ?LOG_ERROR("create the design document ~p : ~p", [DDocId, Error]), % create the design document - {ok, AuthDesign} = auth_design_doc(DDocId, VName), + {ok, AuthDesign} = auth_design_doc(DDocId), {ok, _Rev} = couch_db:update_doc(Db, AuthDesign, []), - ?LOG_ERROR("created the design document", []), ok end. -auth_design_doc(DocId, VName) -> +% add the validation function here +auth_design_doc(DocId) -> DocProps = [ {<<"_id">>, DocId}, {<<"language">>,<<"javascript">>}, {<<"views">>, - {[{VName, + {[{<<"users">>, {[{<<"map">>, <<"function (doc) {\n if (doc.type == \"user\") {\n emit(doc.username, doc);\n}\n}">> }]} }]} + }, + { + <<"validate_doc_update">>, + <<"function(newDoc, oldDoc, userCtx) { + if (newDoc.type != 'user') { + return; + } // we only validate user docs for now + if (!newDoc.username) { + throw({forbidden : 'doc.username is required'}); + } + if (!(newDoc.roles && (typeof newDoc.roles.length != 'undefined') )) { + throw({forbidden : 'doc.roles must be an array'}); + } + if (newDoc._id != 'org.couchdb.user:'+newDoc.username) { + throw({forbidden : 'Docid must be of the form org.couchdb.user:username'}); + } + if (oldDoc) { // validate all updates + if (oldDoc.username != newDoc.username) { + throw({forbidden : 'Usernames may not be changed.'}); + } + } + if (newDoc.password_sha && !newDoc.salt) { + throw({forbidden : 'Users with password_sha must have a salt. See /_utils/script/couch.js for example code.'}); + } + if (userCtx.roles.indexOf('_admin') == -1) { // not an admin + if (oldDoc) { // validate non-admin updates + if (userCtx.name != newDoc.username) { + throw({forbidden : 'You may only update your own user document.'}); + } + // validate role updates + var oldRoles = oldDoc.roles.sort(); + var newRoles = newDoc.roles.sort(); + if (oldRoles.length != newRoles.length) { + throw({forbidden : 'Only _admin may edit roles'}); + } + for (var i=0; i < oldRoles.length; i++) { + if (oldRoles[i] != newRoles[i]) { + throw({forbidden : 'Only _admin may edit roles'}); + } + }; + } else if (newDoc.roles.length > 0) { + throw({forbidden : 'Only _admin may set roles'}); + } + } + // no system roles in users db + for (var i=0; i < newDoc.roles.length; i++) { + if (newDoc.roles[i][0] == '_') { + throw({forbidden : 'No system roles (starting with underscore) in users db.'}); + } + }; + // no system names as usernames + if (newDoc.username[0] == '_') { + throw({forbidden : 'Username may not start with underscore.'}); + } + }">> }], {ok, couch_doc:from_json_obj({DocProps})}. - - -user_doc(DocId, Username, UserSalt, PasswordHash, Email, Active, Roles) -> - user_doc(DocId, Username, UserSalt, PasswordHash, Email, Active, Roles, nil). -user_doc(DocId, Username, UserSalt, PasswordHash, Email, Active, Roles, Rev) -> - DocProps = [ - {<<"_id">>, DocId}, - {<<"type">>, <<"user">>}, - {<<"username">>, Username}, - {<<"password_sha">>, PasswordHash}, - {<<"salt">>, UserSalt}, - {<<"email">>, Email}, - {<<"active">>, Active}, - {<<"roles">>, Roles}], - DocProps1 = case Rev of - nil -> DocProps; - _Rev -> - [{<<"_rev">>, Rev}] ++ DocProps - end, - {ok, couch_doc:from_json_obj({DocProps1})}. -cookie_auth_user(#httpd{mochi_req=MochiReq}=Req) -> +cookie_authentication_handler(#httpd{mochi_req=MochiReq}=Req) -> case MochiReq:get_cookie_value("AuthSession") of - undefined -> nil; - [] -> nil; + undefined -> Req; + [] -> Req; Cookie -> - AuthSession = couch_util:decodeBase64Url(Cookie), - [User, TimeStr | HashParts] = string:tokens(?b2l(AuthSession), ":"), + [User, TimeStr | HashParts] = try + AuthSession = couch_util:decodeBase64Url(Cookie), + [A, B | Cs] = string:tokens(?b2l(AuthSession), ":") + catch + _:Error -> + Reason = <<"Malformed AuthSession cookie. Please clear your cookies.">>, + throw({bad_request, Reason}) + end, % Verify expiry and hash {NowMS, NowS, _} = erlang:now(), CurrentTime = NowMS * 1000000 + NowS, case couch_config:get("couch_httpd_auth", "secret", nil) of - nil -> nil; + nil -> + ?LOG_ERROR("cookie auth secret is not set",[]), + Req; SecretStr -> Secret = ?l2b(SecretStr), case get_user(?l2b(User)) of - nil -> nil; - Result -> - UserSalt = proplists:get_value(<<"salt">>, Result, <<"">>), + nil -> Req; + UserProps -> + UserSalt = proplists:get_value(<<"salt">>, UserProps, <<"">>), FullSecret = <>, ExpectedHash = crypto:sha_mac(FullSecret, User ++ ":" ++ TimeStr), Hash = ?l2b(string:join(HashParts, ":")), @@ -230,10 +266,11 @@ cookie_auth_user(#httpd{mochi_req=MochiReq}=Req) -> ?LOG_DEBUG("Successful cookie auth as: ~p", [User]), Req#httpd{user_ctx=#user_ctx{ name=?l2b(User), - roles=proplists:get_value(<<"roles">>, Result, []) + roles=proplists:get_value(<<"roles">>, UserProps, []), + user_doc=proplists:get_value(<<"user_doc">>, UserProps, null) }, auth={FullSecret, TimeLeft < Timeout*0.9}}; _Else -> - nil + Req end end end @@ -270,8 +307,19 @@ cookie_auth_cookie(User, Secret, TimeStamp) -> hash_password(Password, Salt) -> ?l2b(couch_util:to_hex(crypto:sha(<>))). +ensure_cookie_auth_secret() -> + case couch_config:get("couch_httpd_auth", "secret", nil) of + nil -> + NewSecret = ?b2l(couch_uuids:random()), + couch_config:set("couch_httpd_auth", "secret", NewSecret), + NewSecret; + Secret -> Secret + end. + +% session handlers % Login handler with user db -handle_login_req(#httpd{method='POST', mochi_req=MochiReq}=Req) -> +% TODO this should also allow a JSON POST +handle_session_req(#httpd{method='POST', mochi_req=MochiReq}=Req) -> ReqBody = MochiReq:recv_body(), Form = case MochiReq:get_primary_header_value("content-type") of "application/x-www-form-urlencoded" ++ _ -> @@ -281,6 +329,7 @@ handle_login_req(#httpd{method='POST', mochi_req=MochiReq}=Req) -> end, UserName = ?l2b(proplists:get_value("username", Form, "")), Password = ?l2b(proplists:get_value("password", Form, "")), + ?LOG_DEBUG("Attempt Login: ~s",[UserName]), User = case get_user(UserName) of nil -> []; Result -> Result @@ -289,10 +338,12 @@ handle_login_req(#httpd{method='POST', mochi_req=MochiReq}=Req) -> PasswordHash = hash_password(Password, UserSalt), case proplists:get_value(<<"password_sha">>, User, nil) of ExpectedHash when ExpectedHash == PasswordHash -> - Secret = ?l2b(couch_config:get("couch_httpd_auth", "secret", nil)), + % setup the session cookie + Secret = ?l2b(ensure_cookie_auth_secret()), {NowMS, NowS, _} = erlang:now(), CurrentTime = NowMS * 1000000 + NowS, Cookie = cookie_auth_cookie(?b2l(UserName), <>, CurrentTime), + % TODO document the "next" feature in Futon {Code, Headers} = case couch_httpd:qs_value(Req, "next", nil) of nil -> {200, [Cookie]}; @@ -300,32 +351,38 @@ handle_login_req(#httpd{method='POST', mochi_req=MochiReq}=Req) -> {302, [Cookie, {"Location", couch_httpd:absolute_uri(Req, Redirect)}]} end, send_json(Req#httpd{req_body=ReqBody}, Code, Headers, - {[{ok, true}]}); + {[ + {ok, true}, + {name, proplists:get_value(<<"username">>, User, null)}, + {roles, proplists:get_value(<<"roles">>, User, [])}, + {user_doc, proplists:get_value(<<"user_doc">>, User, null)} + ]}); _Else -> - throw({unauthorized, <<"Name or password is incorrect.">>}) - end. - -% Session Handler - -handle_session_req(#httpd{method='POST'}=Req) -> - handle_login_req(Req); + % clear the session + Cookie = mochiweb_cookies:cookie("AuthSession", "", [{path, "/"}, {http_only, true}]), + send_json(Req, 401, [Cookie], {[{error, <<"unauthorized">>},{reason, <<"Name or password is incorrect.">>}]}) + end; +% get user info handle_session_req(#httpd{method='GET', user_ctx=UserCtx}=Req) -> - % whoami Name = UserCtx#user_ctx.name, - Roles = UserCtx#user_ctx.roles, ForceLogin = couch_httpd:qs_value(Req, "basic", "false"), case {Name, ForceLogin} of {null, "true"} -> throw({unauthorized, <<"Please login.">>}); - _False -> ok - end, - send_json(Req, {[ - {ok, true}, - {name, Name}, - {roles, Roles} - ]}); + {Name, _} -> + send_json(Req, {[ + {ok, true}, + {name, Name}, + {roles, UserCtx#user_ctx.roles}, + {info, {[ + {user_db, ?l2b(couch_config:get("couch_httpd_auth", "authentication_db"))}, + {handlers, [?l2b(H) || H <- couch_httpd:make_fun_spec_strs( + couch_config:get("httpd", "authentication_handlers"))]} + ] ++ maybe_value(authenticated, UserCtx#user_ctx.handler)}} + ] ++ maybe_value(user_doc, UserCtx#user_ctx.user_doc)}) + end; +% logout by deleting the session handle_session_req(#httpd{method='DELETE'}=Req) -> - % logout Cookie = mochiweb_cookies:cookie("AuthSession", "", [{path, "/"}, {http_only, true}]), {Code, Headers} = case couch_httpd:qs_value(Req, "next", nil) of nil -> @@ -336,135 +393,9 @@ handle_session_req(#httpd{method='DELETE'}=Req) -> send_json(Req, Code, Headers, {[{ok, true}]}); handle_session_req(Req) -> send_method_not_allowed(Req, "GET,HEAD,POST,DELETE"). - -create_user_req(#httpd{method='POST', mochi_req=MochiReq}=Req, Db) -> - ReqBody = MochiReq:recv_body(), - Form = case MochiReq:get_primary_header_value("content-type") of - "application/x-www-form-urlencoded" ++ _ -> - ?LOG_INFO("body parsed ~p", [mochiweb_util:parse_qs(ReqBody)]), - mochiweb_util:parse_qs(ReqBody); - _ -> - [] - end, - Roles = proplists:get_all_values("roles", Form), - UserName = ?l2b(proplists:get_value("username", Form, "")), - Password = ?l2b(proplists:get_value("password", Form, "")), - Email = ?l2b(proplists:get_value("email", Form, "")), - Active = couch_httpd_view:parse_bool_param(proplists:get_value("active", Form, "true")), - case get_user(UserName) of - nil -> - Roles1 = case Roles of - [] -> Roles; - _ -> - ok = couch_httpd:verify_is_server_admin(Req), - [?l2b(R) || R <- Roles] - end, - - UserSalt = couch_uuids:random(), - PasswordHash = hash_password(Password, UserSalt), - DocId = couch_uuids:random(), - {ok, UserDoc} = user_doc(DocId, UserName, UserSalt, PasswordHash, Email, Active, Roles1), - {ok, _Rev} = couch_db:update_doc(Db, UserDoc, []), - ?LOG_DEBUG("User ~s (~s) with password, ~s created.", [?b2l(UserName), ?b2l(DocId), ?b2l(Password)]), - {Code, Headers} = case couch_httpd:qs_value(Req, "next", nil) of - nil -> - {200, []}; - Redirect -> - {302, [{"Location", couch_httpd:absolute_uri(Req, Redirect)}]} - end, - send_json(Req, Code, Headers, {[{ok, true}]}); - _Result -> - ?LOG_DEBUG("Can't create ~s: already exists", [?b2l(UserName)]), - throw({forbidden, <<"User already exists.">>}) - end. - -update_user_req(#httpd{method='PUT', mochi_req=MochiReq, user_ctx=UserCtx}=Req, Db, UserName) -> - Name = UserCtx#user_ctx.name, - UserRoles = UserCtx#user_ctx.roles, - case User = get_user(UserName) of - nil -> - throw({not_found, <<"User doesn't exist">>}); - _Result -> - ReqBody = MochiReq:recv_body(), - Form = case MochiReq:get_primary_header_value("content-type") of - "application/x-www-form-urlencoded" ++ _ -> - mochiweb_util:parse_qs(ReqBody); - _ -> - [] - end, - Roles = proplists:get_all_values("roles", Form), - Password = ?l2b(proplists:get_value("password", Form, "")), - Email = ?l2b(proplists:get_value("email", Form, "")), - Active = couch_httpd_view:parse_bool_param(proplists:get_value("active", Form, "true")), - OldPassword = proplists:get_value("old_password", Form, ""), - OldPassword1 = ?l2b(OldPassword), - UserSalt = proplists:get_value(<<"salt">>, User, <<>>), - OldRev = proplists:get_value(<<"_rev">>, User, <<>>), - DocId = proplists:get_value(<<"_id">>, User, <<>>), - CurrentPasswordHash = proplists:get_value(<<"password_sha">>, User, nil), - - - Roles1 = case Roles of - [] -> Roles; - _ -> - ok = couch_httpd:verify_is_server_admin(Req), - [?l2b(R) || R <- Roles] - end, - - PasswordHash = case lists:member(<<"_admin">>, UserRoles) of - true -> - case Password of - <<>> -> CurrentPasswordHash; - _Else -> - hash_password(Password, UserSalt) - end; - false when Name =:= UserName -> - %% for user we test old password before allowing change - case Password of - <<>> -> - CurrentPasswordHash; - _P when OldPassword =:= [] -> - throw({forbidden, <<"Old password is incorrect.">>}); - _Else -> - OldPasswordHash = hash_password(OldPassword1, UserSalt), - ?LOG_DEBUG("~p == ~p", [CurrentPasswordHash, OldPasswordHash]), - case CurrentPasswordHash of - ExpectedHash when ExpectedHash =:= OldPasswordHash -> - hash_password(Password, UserSalt); - _ -> - throw({forbidden, <<"Old password is incorrect.">>}) - end - end; - _ -> - throw({forbidden, <<"You aren't allowed to change this password.">>}) - end, - {ok, UserDoc} = user_doc(DocId, UserName, UserSalt, PasswordHash, Email, Active, Roles1, OldRev), - {ok, _Rev} = couch_db:update_doc(Db, UserDoc, []), - ?LOG_DEBUG("User ~s (~s)updated.", [?b2l(UserName), ?b2l(DocId)]), - {Code, Headers} = case couch_httpd:qs_value(Req, "next", nil) of - nil -> {200, []}; - Redirect -> - {302, [{"Location", couch_httpd:absolute_uri(Req, Redirect)}]} - end, - send_json(Req, Code, Headers, {[{ok, true}]}) - end. -handle_user_req(#httpd{method='POST'}=Req) -> - DbName = couch_config:get("couch_httpd_auth", "authentication_db"), - ensure_users_db_exists(?l2b(DbName)), - {ok, Db} = couch_db:open(?l2b(DbName), - [{user_ctx, #user_ctx{roles=[<<"_admin">>]}}]), - create_user_req(Req, Db); -handle_user_req(#httpd{method='PUT', path_parts=[_]}=_Req) -> - throw({bad_request, <<"Username is missing">>}); -handle_user_req(#httpd{method='PUT', path_parts=[_, UserName]}=Req) -> - DbName = couch_config:get("couch_httpd_auth", "authentication_db"), - ensure_users_db_exists(?l2b(DbName)), - {ok, Db} = couch_db:open(?l2b(DbName), - [{user_ctx, #user_ctx{roles=[<<"_admin">>]}}]), - update_user_req(Req, Db, UserName); -handle_user_req(Req) -> - couch_httpd:send_method_not_allowed(Req, "POST,PUT"). +maybe_value(Key, undefined) -> []; +maybe_value(Key, Else) -> [{Key, Else}]. to_int(Value) when is_binary(Value) -> to_int(?b2l(Value)); @@ -472,25 +403,3 @@ to_int(Value) when is_list(Value) -> list_to_integer(Value); to_int(Value) when is_integer(Value) -> Value. - -% % Login handler -% handle_login_req(#httpd{method='POST'}=Req) -> -% DbName = couch_config:get("couch_httpd_auth", "authentication_db"), -% case couch_db:open(?l2b(DbName), [{user_ctx, #user_ctx{roles=[<<"_admin">>]}}]) of -% {ok, Db} -> handle_login_req(Req, Db) -% end; -% handle_login_req(Req) -> -% send_method_not_allowed(Req, "POST"). -% -% % Logout handler -% handle_logout_req(#httpd{method='POST'}=Req) -> -% Cookie = mochiweb_cookies:cookie("AuthSession", "", [{path, "/"}, {http_only, true}]), -% {Code, Headers} = case couch_httpd:qs_value(Req, "next", nil) of -% nil -> -% {200, [Cookie]}; -% Redirect -> -% {302, [Cookie, {"Location", couch_httpd:absolute_uri(Req, Redirect)}]} -% end, -% send_json(Req, Code, Headers, {[{ok, true}]}); -% handle_logout_req(Req) -> -% send_method_not_allowed(Req, "POST"). -- cgit v1.2.3