using Astralis; using Json; namespace UsmWeb { /** Errors raised by a malformed `"usm-web"` configuration section. */ public errordomain UsmWebError { /** The section (or its `installer` object) is structurally invalid */ INVALID_CONFIGURATION, } /** How the "Install USM" instructions source the installer script. */ public enum InstallerSource { /** No installer configured; the homepage omits the install block. */ NONE, /** The installer lives at an external URL; instructions link out. */ URL, /** The installer is a local file this app serves at /install-usm.sh. */ PATH; /** The wire/display name used as the homepage's `installer_mode`. */ public string to_string() { switch (this) { case InstallerSource.NONE: return "none"; case InstallerSource.URL: return "url"; case InstallerSource.PATH: return "path"; default: assert_not_reached(); } } } /** * The `"usm-web"` section of web-config.json. * * ```json * { * "usm-web": { * "repo": "/repo", * "repositories": ["web-stack", "extra"], * "installer": { "path": "install-usm.sh" }, * "base_url": "https://repo.example.com/", * "name": "My repositories" * } * } * ``` * * `repo` is the parent directory holding the repositories this app * serves: each is a subdirectory containing a `.usmr` and a `public/` * directory. A relative value resolves against the directory of the * config file that declared it. It applies only when neither the * `--repo` argument nor `USM_WEB_REPO_DIR` is given (precedence: * argument > environment > config > `/repo`). * * `repositories` optionally names the subdirectories to serve (in that * order); when absent (or empty) every subdirectory containing a `.usmr` * is discovered by scanning. A single-repository layout with the `.usmr` * directly inside `repo` is still served as one repository. * * `installer` carries exactly one of `url` (an external location the * homepage links to) or `path` (a file the app serves itself at * {@link InstallerScriptEndpoint}); a relative `path` resolves against * the directory of the config file that declared it. `base_url` overrides * the request-derived repository base (normalised to a trailing slash) * and `name` overrides the overview title. */ public class UsmWebConfig : GLib.Object { /** The configured installer source, or {@link InstallerSource.NONE}. */ public InstallerSource installer { get; private set; default = InstallerSource.NONE; } /** The external installer URL (`url` mode), or null. */ public string? installer_url { get; private set; } /** The absolute installer script path (`path` mode), or null. */ public string? installer_path { get; private set; } /** The base-URL override (trailing slash), or null to derive per request. */ public string? base_url { get; private set; } /** The overview-title override, or null for the default heading. */ public string? display_name { get; private set; } /** * The parent directory holding the served repositories (each a * subdirectory with a `.usmr` and `public/`). */ public string repo_dir { get; private set; default = "/repo"; } /** * The explicitly listed repository subdirectories under * {@link repo_dir}; empty means scan for every subdirectory holding * a `.usmr`. */ public string[] repositories { get; private set; default = new string[0]; } /** * Reads the section from the loaded {@link WebConfig}, re-parsing the * contributing files with json-glib so the nested `installer` object * and the `repositories` array are readable and a relative * `path`/`repo` resolves against the config file that declared it * (later files win, mirroring WebConfig layering). {@link repo_dir} * wins when given; otherwise the config's `repo` applies, falling * back to `/repo`. */ public static UsmWebConfig load(WebConfig config, string? repo_dir = null) throws Error { var result = new UsmWebConfig(); string? declared_path = null; string declaring_dir = Environment.get_current_dir(); string? declared_repo = null; string repo_declaring_dir = declaring_dir; string[]? declared_repositories = null; foreach (var file in config.get_loaded_files()) { var section = read_section(file); if (section == null) { continue; } var obj = (!)section; result.display_name = read_string_member(obj, "name") ?? result.display_name; var base_url = read_string_member(obj, "base_url"); if (base_url != null) { result.base_url = normalise_base((!)base_url); } var repo = read_string_member(obj, "repo"); if (repo != null) { declared_repo = repo; repo_declaring_dir = GLib.Path.get_dirname((!)file); } var repositories = read_string_array_member(obj, "repositories"); if (repositories != null) { declared_repositories = repositories; } result.installer_url = read_string_member(obj, "installer", "url") ?? result.installer_url; declared_path = read_string_member(obj, "installer", "path") ?? declared_path; if (declared_path != null) { declaring_dir = GLib.Path.get_dirname((!)file); } } if (repo_dir != null) { result.repo_dir = (!)repo_dir; } else if (declared_repo != null) { var repo = (!)declared_repo; result.repo_dir = GLib.Path.is_absolute(repo) ? repo : GLib.Path.build_filename(repo_declaring_dir, repo); } if (declared_repositories != null) { result.repositories = (!)declared_repositories; } if (result.installer_url != null && declared_path != null) { throw new UsmWebError.INVALID_CONFIGURATION( "usm-web.installer: provide exactly one of \"url\" or \"path\", not both"); } if (result.installer_url != null) { result.installer = InstallerSource.URL; } else if (declared_path != null) { var path = (!)declared_path; result.installer_path = GLib.Path.is_absolute(path) ? path : GLib.Path.build_filename(declaring_dir, path); result.installer = InstallerSource.PATH; } return result; } private static string normalise_base(string base_url) { return base_url.has_suffix("/") ? base_url : base_url + "/"; } /** The root object of `path`'s `"usm-web"` member, or null when absent. */ private static Json.Object? read_section(string path) { try { var parser = new Parser(); parser.load_from_file(path); var root = parser.get_root().get_object(); return root.has_member("usm-web") ? root.get_member("usm-web").get_object() : null; } catch (Error e) { return null; } } private static string? read_string_member(Json.Object obj, string member, string? nested = null) { if (!obj.has_member(member)) { return null; } var node = obj.get_member(member); if (nested != null) { if (node.get_node_type() != NodeType.OBJECT) { return null; } var inner = node.get_object(); if (!inner.has_member(nested)) { return null; } node = inner.get_member(nested); } return node.get_node_type() == NodeType.VALUE ? node.get_string() : null; } /** The string values of `member` (when it is a JSON array), or null. */ private static string[]? read_string_array_member(Json.Object obj, string member) { if (!obj.has_member(member)) { return null; } var node = obj.get_member(member); if (node.get_node_type() != NodeType.ARRAY) { return null; } var array = node.get_array(); var values = new string[0]; for (var i = 0; i < array.get_length(); i++) { var element = array.get_element(i); if (element.get_node_type() == NodeType.VALUE) { values += element.get_string(); } } return values; } } }