src/support/z_config_files.erl

%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2019-2021 Marc Worrell
%% @doc Generic support for finding and parsing config files.

%% Copyright 2019-2021 Marc Worrell
%%
%% 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(z_config_files).

-export([
    security_dir/0,
    log_dir/0,
    data_dir/0,
    cache_dir/0,
    config_dir/0,
    config_dir/1,
    files/1,
    files/2,
    consult/1
]).

-include_lib("zotonic_core/include/zotonic_release.hrl").
-include_lib("yamerl/include/yamerl_errors.hrl").
-include_lib("kernel/include/logger.hrl").


%% @doc Find the default directory for certificates and other secrets.
%% Checks the following locations:
%%
%% <ol>
%%   <li>The environment variable <tt>ZOTONIC_SECURITY_DIR</tt></li>
%%   <li>The directory <tt>$HOME/.zotonic/security</tt></li>
%%   <li>The directory <tt>/etc/zotonic/security</tt>  (only on Unix)</li>
%%   <li>The OS specific directory for application config files</li>
%% </ol>
%%
%% If no directory is found then the OS specific directory with the
%% the subdirectory <tt>security</tt> is used:
%%
%% <ol>
%%   <li>Linux: <tt>$HOME/.config/zotonic/security/</tt></li>
%%   <li>macOS: <tt>$HOME/Library/Application Support/zotonic/security/</tt></li>
%% </ol>
%%
-spec security_dir() -> {ok, file:filename_all()} | {error, term()}.
security_dir() ->
    case os:getenv("ZOTONIC_SECURITY_DIR") of
        false ->
            security_dir_1();
        "" ->
            security_dir_1();
        Dir ->
            case filelib:is_dir(Dir) of
                true -> {ok, Dir};
                false -> {error, enoent}
            end
    end.

security_dir_1() ->
    HomeLocs = case os:getenv("HOME") of
        false -> [];
        "" -> [];
        Home ->
            [
                filename:join([Home, ".zotonic", "security"])
            ]
    end,
    EtcLocs = case os:type() of
        {unix, _} ->
            [
                filename:join(["/etc/zotonic", "security"])
            ];
        {_, _} ->
            []
    end,
    SystemConfigDir = filename:basedir(user_config, "zotonic"),
    SystemLocs = [
        filename:join([SystemConfigDir, "security" ])
    ],
    Locs = HomeLocs ++ EtcLocs ++ SystemLocs,
    case lists:dropwhile(fun(D) -> not filelib:is_dir(D) end, Locs) of
        [] ->
            % Use the OS specific default
            SecurityDir = filename:join([SystemConfigDir, "security"]),
            % The '$HOME/.config' dir is not pre-created on some Linux VMs
            _ = z_filelib:ensure_dir(SystemConfigDir),
            case file:make_dir(SystemConfigDir) of
                ok -> file:change_mode(SystemConfigDir, 8#00700);
                {error, _} -> ok
            end,
            case file:make_dir(SecurityDir) of
                ok -> file:change_mode(SecurityDir, 8#00700);
                {error, _} -> ok
            end,
            case filelib:is_dir(SecurityDir) of
                true ->
                    ?LOG_INFO(#{
                        text => <<"Created security directory">>,
                        in => zotonic_core,
                        path => SecurityDir
                    }),
                    {ok, SecurityDir};
                false ->
                    ?LOG_ERROR(#{
                        text => <<"Could not create security directory">>,
                        in => zotonic_core,
                        path => SecurityDir
                    }),
                    {error, enoent}
            end;
        [ D | _ ] ->
            {ok, D}
    end.

%% @doc Find the default directory for log files.
%% Checks the following locations:
%%
%% <ol>
%%   <li>The environment variable <tt>ZOTONIC_LOG_DIR</tt></li>
%%   <li>Local working directory <tt>logs</tt></li>
%%   <li>The OS specific directory for application log files</li>
%% </ol>
%%
%% If no directory is found then the OS specific directory is used:
%%
%% <ol>
%%   <li>Linux: <tt>$HOME/.cache/zotonic/log/</tt></li>
%%   <li>macOS: <tt>$HOME/Library/Logs/zotonic//</tt></li>
%% </ol>
%%
-spec log_dir() -> {ok, file:filename_all()} | {error, term()}.
log_dir() ->
    case os:getenv("ZOTONIC_LOG_DIR") of
        false ->
            logs_dir_1();
        "" ->
            logs_dir_1();
        Dir ->
            case filelib:is_dir(Dir) of
                true -> {ok, Dir};
                false -> {error, enoent}
            end
    end.

logs_dir_1() ->
    HomeLocs = [
        "logs"
    ],
    SystemLogDir = filename:basedir(user_log, "zotonic"),
    SystemLocs = [
        SystemLogDir
    ],
    Locs = HomeLocs ++ SystemLocs,
    case lists:dropwhile(fun(D) -> not filelib:is_dir(D) end, Locs) of
        [] ->
            % Use the OS specific default
            % The '$HOME/.config' dir is not pre-created on some Linux VMs
            _ = z_filelib:ensure_dir(SystemLogDir),
            case file:make_dir(SystemLogDir) of
                ok -> file:change_mode(SystemLogDir, 8#00700);
                {error, _} -> ok
            end,
            case filelib:is_dir(SystemLogDir) of
                true ->
                    ?LOG_INFO(#{
                        text => <<"Create log directory">>,
                        in => zotonic_core,
                        path => SystemLogDir
                    }),
                    {ok, SystemLogDir};
                false ->
                    ?LOG_ERROR(#{
                        text => <<"Could not create log directory">>,
                        in => zotonic_core,
                        path => SystemLogDir
                    }),
                    {error, enoent}
            end;
        [ D | _ ] ->
            {ok, D}
    end.

%% @doc Find the default directory for data files.
%% Checks the following locations:
%%
%% <ol>
%%   <li>The environment variable <tt>ZOTONIC_DATA_DIR</tt></li>
%%   <li>Local working directory <tt>data</tt></li>
%%   <li>The OS specific directory for application data files</li>
%% </ol>
%%
%% If no directory is found then the OS specific directory is used:
%%
%% <ol>
%%   <li>Linux: <tt>$HOME/.local/share/zotonic/</tt></li>
%%   <li>macOS: <tt>$HOME/Library/Application Support/zotonic/</tt></li>
%% </ol>
%%
-spec data_dir() -> {ok, file:filename_all()} | {error, term()}.
data_dir() ->
    case os:getenv("ZOTONIC_DATA_DIR") of
        false ->
            data_dir_1();
        "" ->
            data_dir_1();
        Dir ->
            case filelib:is_dir(Dir) of
                true -> {ok, Dir};
                false -> {error, enoent}
            end
    end.

data_dir_1() ->
    HomeLocs = [
        "data"
    ],
    SystemDataDir = filename:basedir(user_data, "zotonic"),
    SystemLocs = [
        SystemDataDir
    ],
    Locs = HomeLocs ++ SystemLocs,
    case lists:dropwhile(fun(D) -> not filelib:is_dir(D) end, Locs) of
        [] ->
            % Use the OS specific default
            % The '$HOME/.config' dir is not pre-created on some Linux VMs
            _ = z_filelib:ensure_dir(SystemDataDir),
            case file:make_dir(SystemDataDir) of
                ok -> file:change_mode(SystemDataDir, 8#00700);
                {error, _} -> ok
            end,
            case filelib:is_dir(SystemDataDir) of
                true ->
                    ?LOG_INFO(#{
                        text => <<"Created data directory">>,
                        in => zotonic_core,
                        path => SystemDataDir
                    }),
                    {ok, SystemDataDir};
                false ->
                    ?LOG_ERROR(#{
                        text => <<"Could not create data directory">>,
                        in => zotonic_core,
                        path => SystemDataDir
                    }),
                    {error, enoent}
            end;
        [ D | _ ] ->
            {ok, D}
    end.


%% @doc Find the default directory for cache files.
%% Checks the following locations:
%%
%% <ol>
%%   <li>The environment variable <tt>ZOTONIC_CACHE_DIR</tt></li>
%%   <li>Local working directory <tt>caches</tt></li>
%%   <li>The OS specific directory for application cache files</li>
%% </ol>
%%
%% If no directory is found then the OS specific directory is used:
%%
%% <ol>
%%   <li>Linux: <tt>$HOME/.cache/zotonic/</tt></li>
%%   <li>macOS: <tt>$HOME/Library/Caches/zotonic/</tt></li>
%% </ol>
%%
-spec cache_dir() -> {ok, file:filename_all()} | {error, term()}.
cache_dir() ->
    case os:getenv("ZOTONIC_LOG_DIR") of
        false ->
            cache_dir_1();
        "" ->
            cache_dir_1();
        Dir ->
            case filelib:is_dir(Dir) of
                true -> {ok, Dir};
                false -> {error, enoent}
            end
    end.

cache_dir_1() ->
    HomeLocs = [
        "caches"
    ],
    SystemCacheDir = filename:basedir(user_cache, "zotonic"),
    SystemLocs = [
        SystemCacheDir
    ],
    Locs = HomeLocs ++ SystemLocs,
    case lists:dropwhile(fun(D) -> not filelib:is_dir(D) end, Locs) of
        [] ->
            % Use the OS specific default
            % The '$HOME/.config' dir is not pre-created on some Linux VMs
            _ = z_filelib:ensure_dir(SystemCacheDir),
            case file:make_dir(SystemCacheDir) of
                ok -> file:change_mode(SystemCacheDir, 8#00700);
                {error, _} -> ok
            end,
            case filelib:is_dir(SystemCacheDir) of
                true ->
                    ?LOG_INFO(#{
                        text => <<"Created cache directory">>,
                        in => zotonic_core,
                        path => SystemCacheDir
                    }),
                    {ok, SystemCacheDir};
                false ->
                    ?LOG_ERROR(#{
                        text => <<"Could not create cache directory">>,
                        in => zotonic_core,
                        path => SystemCacheDir
                    }),
                    {error, enoent}
            end;
        [ D | _ ] ->
            {ok, D}
    end.

%% @doc Find the directory with the configuration files. Defaults to the
%% OS specific directory for all configurations. This checks a list
%% of possible locations:
%%
%% <ol>
%%   <li>The init argument <tt>zotonic_config_dir</tt></li>
%%   <li>The environment variable <tt>ZOTONIC_CONFIG_DIR</tt></li>
%%   <li>The directory <tt>$HOME/.zotonic</tt></li>
%%   <li>The directory <tt>/etc/zotonic</tt>  (only on Unix)</li>
%%   <li>The OS specific directory for application config files</li>
%% </ol>
%%
%% In the last three cases subdirectories are also checked, in
%% the following order:
%%
%% <ol>
%%   <li>The complete Erlang node name</li>
%%   <li>The short node name without the server address</li>
%%   <li>The complete Zotonic version (eg. 1.2.3)</li>
%%   <li>The minor Zotonic version (eg. 1.2)</li>
%%   <li>The major Zotonic version (eg. 1)</li>
%%   <li>The directory itself, without any version</li>
%% </ol>
%%
%% If no directory is found then the OS specific directory with the
%% the major Zotonic version is used. Examples:
%%
%% <ol>
%%   <li>Linux: <tt>$HOME/.config/zotonic/config/1/</tt></li>
%%   <li>macOS: <tt>$HOME/Library/Application Support/zotonic/config/1/</tt></li>
%% </ol>
%%
-spec config_dir() -> {ok, file:filename_all()} | {error, term()}.
config_dir() ->
    config_dir( node() ).

-spec config_dir( node() ) -> {ok, file:filename_all()} | {error, term()}.
config_dir(Node) ->
    case proplists:get_value(zotonic_config_dir, init:get_arguments()) of
        undefined ->
            config_dir_env(Node);
        [] ->
            config_dir_env(Node);
        [ Dir ] ->
            case filelib:is_dir(Dir) of
                true -> {ok, Dir};
                false -> {error, enoent}
            end
    end.

config_dir_env(Node) ->
    case os:getenv("ZOTONIC_CONFIG_DIR") of
        false ->
            config_dir_find(Node);
        "" ->
            config_dir_find(Node);
        Dir ->
            case filelib:is_dir(Dir) of
                true -> {ok, Dir};
                false -> {error, enoent}
            end
    end.

config_dir_find(Node) ->
    {MajorVersion, MinorVersion} = split_version(?ZOTONIC_VERSION),
    HomeLocs = case os:getenv("HOME") of
        false -> [];
        "" -> [];
        Home ->
            [
                filename:join([Home, ".zotonic", atom_to_list(Node)]),
                filename:join([Home, ".zotonic", base_nodename(Node)]),
                filename:join([Home, ".zotonic", ?ZOTONIC_VERSION]),
                filename:join([Home, ".zotonic", MinorVersion]),
                filename:join([Home, ".zotonic", MajorVersion]),
                filename:join([Home, ".zotonic"])
            ]
    end,
    EtcLocs = case os:type() of
        {unix, _} ->
            [
                filename:join(["/etc/zotonic", atom_to_list(Node)]),
                filename:join(["/etc/zotonic", base_nodename(Node)]),
                filename:join(["/etc/zotonic", ?ZOTONIC_VERSION]),
                filename:join(["/etc/zotonic", MinorVersion]),
                filename:join(["/etc/zotonic", MajorVersion]),
                filename:join(["/etc/zotonic"])
            ];
        {_, _} ->
            []
    end,
    SystemConfigDir = filename:basedir(user_config, "zotonic"),
    % Use 'config' subdir as on macOS the user_config and user_data
    % directories are the same.
    SystemLocs = [
        filename:join([SystemConfigDir, "config", atom_to_list(Node)]),
        filename:join([SystemConfigDir, "config", base_nodename(Node)]),
        filename:join([SystemConfigDir, "config", ?ZOTONIC_VERSION]),
        filename:join([SystemConfigDir, "config", MinorVersion]),
        filename:join([SystemConfigDir, "config", MajorVersion]),
        filename:join([SystemConfigDir, "config" ])
    ],
    Locs = HomeLocs ++ EtcLocs ++ SystemLocs,
    case lists:dropwhile(fun is_empty_config_dir/1, Locs) of
        [] ->
            % Use the OS specific default
            ZotonicDir = filename:join([SystemConfigDir, "config"]),
            VersionDir = filename:join([SystemConfigDir, "config", MajorVersion]),
            % The '$HOME/.config' dir is not pre-created on some Linux VMs
            _ = z_filelib:ensure_dir(SystemConfigDir),
            case file:make_dir(SystemConfigDir) of
                ok -> file:change_mode(SystemConfigDir, 8#00700);
                {error, _} -> ok
            end,
            case file:make_dir(ZotonicDir) of
                ok -> file:change_mode(ZotonicDir, 8#00700);
                {error, _} -> ok
            end,
            case file:make_dir(VersionDir) of
                ok -> file:change_mode(VersionDir, 8#00700);
                {error, _} -> ok
            end,
            case filelib:is_dir(VersionDir) of
                true ->
                    {ok, VersionDir};
                false ->
                    ?LOG_ERROR(#{
                        text => <<"Could not create config directory">>,
                        in => zotonic_core,
                        path => VersionDir,
                        result => error,
                        reason => not_directory
                    }),
                    {error, enoent}
            end;
        [ D | _ ] ->
            {ok, D}
    end.

%% @doc If there is a file starting with 'zotonic' in the directory, then we assume
%% the directory is in use for configuration files. Otherwise check another directory
%% for the presence of zotonic config files.
is_empty_config_dir(Dir) ->
    files(Dir, "zotonic*") =:= [].

base_nodename(Node) ->
    lists:takewhile(fun(C) -> C =/= $@ end, atom_to_list(Node)).

%% @doc List all (regular) files in a directory, skip hidden and temp files.
-spec files( file:filename_all() ) -> [ file:filename_all() ].
files(Dir) ->
    files(Dir, "*").

%% @doc List all (regular) files in a directory, skip hidden and temp files.
%%      Ensures the list of files is sorted in a consistent way.
-spec files( file:filename_all(), string() ) -> [ file:filename_all() ].
files(Dir, Wildcard) ->
    Files = filelib:wildcard( filename:join(Dir, Wildcard) ),
    lists:sort(
        lists:filter(
            fun(F) ->
                case filename:basename(F) of
                    "." ++ _ -> false;
                    _ ->
                        not filelib:is_dir(F)
                        andalso lists:last(F) =/= $#
                        andalso lists:last(F) =/= $~
                end
            end,
            Files)).

%% @doc Read a config file, return a list of the contents.
%%      The file can be in erlang, yaml, or json format.
-spec consult( file:filename_all() ) -> {ok, list( map() | proplists:proplist() )} | {error, term()}.
consult(File) ->
    case z_convert:to_list( filename:extension(File) ) of
        ".config" ->
            consult_erlang(File);
        ".erlang" ->
            consult_erlang(File);
        ".yml" ->
            consult_yaml(File);
        ".yaml" ->
            consult_yaml(File);
        ".json" ->
            consult_json(File);
        _Other ->
            {error, {config_file, unknown_format, File, undefined}}
    end.

consult_erlang(File) ->
    case file:consult(File) of
        {ok, L} when is_list(L) ->
            {ok, L};
        {error, Reason} when is_atom(Reason) ->
            {error, {config_file, Reason, File, undefined}};
        {error, Reason} ->
            {error, {config_file, consult_error, File, Reason}}
    end.

consult_yaml(File) ->
    try
        Options = [
            str_node_as_binary,
            {map_node_format, map}
        ],
        Data = yamerl_constr:file(File, Options),
        {ok, atomify_labels(Data)}
    catch
        throw:#yamerl_exception{} = E ->
            {error, {config_file, yaml_format, File, E}}
    end.

consult_json(File) ->
    case file:read_file(File) of
        {ok, Data} ->
            try
                case jsx:decode(Data, [ return_maps, {labels, atom} ]) of
                    Map when is_map(Map) ->
                        {ok, [ Map ]};
                    [ Map | _ ] = List when is_map(Map) ->
                        {ok, List};
                    _ ->
                        {error, {config_file, no_list_or_map, File, undefined}}
                end
            catch
                _:Reason ->
                    {error, {config_file, json_format, File, Reason}}
            end;
        {error, Reason} ->
            {error, {config_file, Reason, File, undefined}}
    end.

split_version(Version) ->
    {match, [Minor, Major]} = re:run(Version, "(([0-9]+)\\.[0-9]+)", [{capture, all_but_first, list}]),
    {Major, Minor}.

atomify_labels(Data) when is_list(Data) ->
    lists:map(fun atomify_labels/1, Data);
atomify_labels(Data) when is_map(Data) ->
    L = lists:map(
        fun({K, V}) ->
            {binary_to_atom(K, utf8), atomify_labels(V)}
        end,
        maps:to_list(Data)),
    maps:from_list(L);
atomify_labels(V) ->
    V.