Source: server/routes/tenant.js

const Router = require("express-promise-router");
const Form = require("@saltcorn/data/models/form");
const { getState, create_tenant } = require("@saltcorn/data/db/state");
const {
  getAllTenants,
  domain_sanitize,
  deleteTenant,
} = require("@saltcorn/data/models/tenant");
const {
  renderForm,
  link,
  post_delete_btn,
  mkTable,
} = require("@saltcorn/markup");
const {
  div,
  nbsp,
  p,
  a,
  h4,
  text,
  i,
  table,
  tr,
  th,
  td,
} = require("@saltcorn/markup/tags");
const db = require("@saltcorn/data/db");
const url = require("url");
const { loadAllPlugins } = require("../load_plugins");
const { setTenant, isAdmin, error_catcher } = require("./utils.js");
const User = require("@saltcorn/data/models/user");
const File = require("@saltcorn/data/models/file");
const {
  send_infoarch_page,
  send_admin_page,
  config_fields_form,
  save_config_from_form,
} = require("../markup/admin.js");
const { getConfig } = require("@saltcorn/data/models/config");

const router = new Router();
module.exports = router;
/**
 * Declare Form to create Tenant
 * @param req - Request
 * @returns {Form} - Saltcorn Form Declaration
 */
// TBD add form field email for tenant admin
const tenant_form = (req) =>
  new Form({
    action: "/tenant/create",
    submitLabel: req.__("Create"),
    labelCols: 4,
    blurb: req.__(
      "Please select a name for your application. The name will determine the address at which it will be available. "
    ),
    fields: [
      {
        name: "subdomain",
        label: req.__("Application name"),
        input_type: "text",
        postText: text(req.hostname),
      },
    ],
  });
/**
 * Check that user has role that allowed to create tenants
 * By default Admin role (id is 10) has rights to create tenants.
 * You can specify config variable "role_to_create_tenant" to overwrite this.
 * Note that only one role currently can have such rights simultaneously.
 * @param req - Request
 * @returns {boolean} true if role has righs to create tenant
 */
// TBD To allow few roles to create tenants - currently only one role has such rights simultaneously
const create_tenant_allowed = (req) => {
  const required_role = +getState().getConfig("role_to_create_tenant") || 10;
  const user_role = req.user ? req.user.role_id : 10;
  return user_role <= required_role;
};
/**
 * Check that String is IPv4 address
 * @param hostname
 * @returns {boolean|this is string[]}
 */
// TBD not sure that false is correct return if type of is not string
// TBD Add IPv6 support
const is_ip_address = (hostname) => {
  if (typeof hostname !== "string") return false;
  return hostname.split(".").every((s) => +s >= 0 && +s <= 255);
};
router.get(
  "/create",
  setTenant,
  error_catcher(async (req, res) => {
    if (
      !db.is_it_multi_tenant() ||
      db.getTenantSchema() !== db.connectObj.default_schema
    ) {
      res.sendWrap(
        req.__("Create application"),
        req.__("Multi-tenancy not enabled")
      );
      return;
    }
    if (!create_tenant_allowed(req)) {
      res.sendWrap(req.__("Create application"), req.__("Not allowed"));
      return;
    }

    if (is_ip_address(req.hostname))
      req.flash(
        "danger",
        req.__(
          "You are trying to create a tenant while connecting via an IP address rather than a domain. This will probably not work."
        )
      );
    if (getState().getConfig("create_tenant_warning"))
      req.flash(
        "warning",
        h4(req.__("Warning")) +
          p(
            req.__(
              "Hosting on this site is provided for free and with no guarantee of availability or security of your application. "
            ) + " " +
              req.__(
                "This facility is intended solely for you to evaluate the suitability of Saltcorn. "
              ) + " " +
              req.__(
                "If you would like to store private information that needs to be secure, please use self-hosted Saltcorn. "
              ) + " " +
              req.__(
                  'See <a href="https://github.com/saltcorn/saltcorn">GitHub repository</a> for instructions<p>'
              )
          )
      );
    res.sendWrap(
      req.__("Create application"),
      renderForm(tenant_form(req), req.csrfToken())
    );
  })
);
/**
 * Return URL of new Tenant
 * @param req - Request
 * @param subdomain - Tenant Subdomain name string
 * @returns {string}
 */
const getNewURL = (req, subdomain) => {
  var ports = "";
  const host = req.get("host");
  if (typeof host === "string") {
    const hosts = host.split(":");
    if (hosts.length > 1) ports = `:${hosts[1]}`;
  }
  const hostname = req.hostname
  const newurl = `${req.protocol}://${subdomain}.${hostname}${ports}/`;

  return newurl;
};
/**
 * Create Tenant UI Main logic
 */
router.post(
  "/create",
  setTenant,
  error_catcher(async (req, res) => {
      // check that multi-tenancy is enabled
    if (
      !db.is_it_multi_tenant() ||
      db.getTenantSchema() !== db.connectObj.default_schema
    ) {
      res.sendWrap(
        req.__("Create application"),
        req.__("Multi-tenancy not enabled")
      );
      return;
    }
    // check that user has rights
    if (!create_tenant_allowed(req)) {
      res.sendWrap(req.__("Create application"), req.__("Not allowed"));
      return;
    }
    // declare  ui form
    const form = tenant_form(req);
    // validate ui form
    const valres = form.validate(req.body);
    if (valres.errors)
      res.sendWrap(
        req.__("Create application"),
        // render ui form if validation finished with error
        renderForm(form, req.csrfToken())
      );
    else {
        // normalize domain name
      const subdomain = domain_sanitize(valres.success.subdomain);
      // get list of tenants
      const allTens = await getAllTenants();
      if (allTens.includes(subdomain) || !subdomain) {
        form.errors.subdomain = req.__(
          "A site with this subdomain already exists"
        );
        form.hasErrors = true;
        res.sendWrap(
          req.__("Create application"),
          renderForm(form, req.csrfToken())
        );
      } else {
        const newurl = getNewURL(req, subdomain);
        await create_tenant(subdomain, loadAllPlugins, newurl);
        res.sendWrap(
          req.__("Create application"),
          div(
            div(req.__("Success! Your new application is available at:")),

            div(
              { class: "my-3", style: "font-size: 22px" },
              a({ href: newurl, class: "new-tenant-link" }, newurl)
            ),
            p(
              req.__(
                "Please click the above link now to create the first user."
              )
            )
          )
        );
      }
    }
  })
);
/**
 * List tenants HTTP GET Web UI
 */
router.get(
  "/list",
  setTenant,
  isAdmin,
  error_catcher(async (req, res) => {
    if (
      !db.is_it_multi_tenant() ||
      db.getTenantSchema() !== db.connectObj.default_schema
    ) {
      res.sendWrap(
        req.__("Create application"),
        req.__("Multi-tenancy not enabled")
      );
      return;
    }
    const tens = await db.select("_sc_tenants");
    send_infoarch_page({
      res,
      req,
      active_sub: "Tenants",
      contents: {
        type: "card",
        title: req.__("Tenants"),
        contents: [
          mkTable(
            [
              {
                label: req.__("Subdomain"),
                key: (r) =>
                  link(getNewURL(req, r.subdomain), text(r.subdomain)),
              },
              {
                  label: req.__("Description"),
                  key: (r) =>
                      text(r.description),
              },
              {
                label: req.__("Information"),
                key: (r) =>
                  a(
                    { href: `/tenant/info/${text(r.subdomain)}` },
                    i({ class: "fas fa-lg fa-info-circle" })
                  ),
              },
              {
                label: req.__("Delete"),
                key: (r) =>
                  post_delete_btn(
                    `/tenant/delete/${r.subdomain}`,
                    req,
                    r.subdomain
                  ),
              },
            ],
            tens
          ),
          div(req.__(`Found %s tenants`,tens.length)),
          div(link("/tenant/create", req.__("Create new tenant"))),
        ],
      },
    });
  })
);
const tenant_settings_form = (req) =>
  config_fields_form({
    req,
    field_names: ["role_to_create_tenant", "create_tenant_warning"],
    action: "/tenant/settings",
    submitLabel: req.__("Save"),
  });

router.get(
  "/settings",
  setTenant,
  isAdmin,
  error_catcher(async (req, res) => {
    if (
      !db.is_it_multi_tenant() ||
      db.getTenantSchema() !== db.connectObj.default_schema
    ) {
      res.sendWrap(
        req.__("Create application"),
        req.__("Multi-tenancy not enabled")
      );
      return;
    }
    const form = await tenant_settings_form(req);

    send_infoarch_page({
      res,
      req,
      active_sub: "Multitenancy settings",
      contents: {
        type: "card",
        title: req.__("Multitenancy settings"),
        contents: [renderForm(form, req.csrfToken())],
      },
    });
  })
);
router.post(
  "/settings",
  setTenant,
  isAdmin,
  error_catcher(async (req, res) => {
    const form = await tenant_settings_form(req);
    form.validate(req.body);
    if (form.hasErrors) {
      send_infoarch_page({
        res,
        req,
        active_sub: "Multitenancy settings",
        contents: {
          type: "card",
          title: req.__("Multitenancy settings"),
          contents: [renderForm(form, req.csrfToken())],
        },
      });
    } else {
      await save_config_from_form(form);

      req.flash("success", req.__("Tenant settings updated"));
      res.redirect("/tenant/settings");
    }
  })
);
/**
 * Get Tenant info
 * @param subdomain
 * @returns {Promise<*>}
 */
// TBD move this function data layer or just separate file(reengineering)
const get_tenant_info = async (subdomain) => {
  const saneDomain = domain_sanitize(subdomain);

  return await db.runWithTenant(saneDomain, async () => {
    let info = {};
    // TBD fix the first user issue because not always firt user by id is creator of tenant
    const firstUser = await User.find({}, { orderBy: "id", limit: 1 });
    if (firstUser && firstUser.length > 0) {
      info.first_user_email = firstUser[0].email;
    }
    // users count
    info.nusers = await db.count("users");
    // roles count
    info.nroles = await db.count("_sc_roles");
    // tables count
    info.ntables = await db.count("_sc_tables");
    // table fields count
    info.nfields = await db.count("_sc_fields");
    // views count
    info.nviews = await db.count("_sc_views");
    // files count
    info.nfiles = await db.count("_sc_files");
    // pages count
    info.npages = await db.count("_sc_pages");
    // triggers (actions) ccount
    info.nactions = await db.count("_sc_triggers");
    // error messages count
    info.nerrors = await db.count("_sc_errors");
    // config items count
    info.nconfigs = await db.count("_sc_config");
    // plugins count
    info.nplugins = await db.count("_sc_plugins");
    // TBD decide Do we need count tenants, table constraints, migrations
    // base url
    info.base_url = await getConfig("base_url");
    return info;
  });
};
/**
 * Tenant info
 */
router.get(
  "/info/:subdomain",
  setTenant,
  isAdmin,
  error_catcher(async (req, res) => {
    if (
      !db.is_it_multi_tenant() ||
      db.getTenantSchema() !== db.connectObj.default_schema
    ) {
      res.sendWrap(
        req.__("Create application"),
        req.__("Multi-tenancy not enabled")
      );
      return;
    }
    const { subdomain } = req.params;
    const info = await get_tenant_info(subdomain);
    // get list of files
    let files;
    await db.runWithTenant(subdomain, async () => {
      files = await File.find({});
    });
    send_infoarch_page({
      res,
      req,
      active_sub: "Tenants",
      sub2_page: text(subdomain),
      contents: {
        above: [
          {
            type: "card",
            title: req.__(`%s tenant statistics`,text(subdomain)),
              // TBD make more pretty view - in ideal with charts
            contents: [
              table(
                tr(th(req.__("E-mail")),     td(a({ href: 'mailto:'+info.first_user_email  }, info.first_user_email))),
                tr(th(req.__("Users")),             td(a({ href: info.base_url+"useradmin"  }, info.nusers))),
                tr(th(req.__("Roles")),             td(a({ href: info.base_url+"roleadmin"  }, info.nroles))),
                tr(th(req.__("Tables")),            td(a({ href: info.base_url+"table"      }, info.ntables))),
                tr(th(req.__("Table columns")),     td(a({ href: info.base_url+"table"      }, info.nfields))),
                tr(th(req.__("Views")),             td(a({ href: info.base_url+"viewedit"   }, info.nviews))),
                tr(th(req.__("Pages")),             td(a({ href: info.base_url+"pageedit"   }, info.npages))),
                tr(th(req.__("Files")),             td(a({ href: info.base_url+"files"      }, info.nfiles))),
                tr(th(req.__("Actions")),           td(a({ href: info.base_url+"actions"    }, info.nactions))),
                tr(th(req.__("Plugins")),           td(a({ href: info.base_url+"plugins"    }, info.nplugins))),
                tr(th(req.__("Configuration items")), td(a({ href: info.base_url+"admin"   }, info.nconfigs))),
                tr(th(req.__("Crashlogs")),         td(a({ href: info.base_url+"crashlog"     }, info.nerrors)))
              ),
            ],
          },
          {
            type: "card",
            title: req.__("Settings"),
            contents: [
              renderForm(
                new Form({
                  action: "/tenant/info/" + text(subdomain),
                  submitLabel: req.__("Save"),
                  submitButtonClass: "btn-outline-primary",
                  onChange: "remove_outline(this)",
                  fields: [
                    { name: "base_url", label: req.__("Base URL"), type: "String" },
                  ],
                  values: { base_url: info.base_url },
                }),
                req.csrfToken()
              ),
            ],
          },
          {
            type: "card",
            title: req.__("Files"),
            contents: mkTable(
              [
                {
                  label: req.__("Name"),
                  key: (r) =>
                    link(
                      `${getNewURL(req, text(subdomain))}files/serve/${r.id}`,
                      r.filename
                    ),
                },
                { label: req.__("Size (KiB)"), key: "size_kb", align: "right" },
                { label: req.__("Media type"), key: (r) => r.mimetype },
              ],
              files
            ),
          },
        ],
      },
    });
  })
);
/**
 * Show Information about Tenant
 * /tenant/info
 */
router.post(
  "/info/:subdomain",
  setTenant,
  isAdmin,
  error_catcher(async (req, res) => {
    if (
      !db.is_it_multi_tenant() ||
      db.getTenantSchema() !== db.connectObj.default_schema
    ) {
      res.sendWrap(
        req.__("Create application"),
        req.__("Multi-tenancy not enabled")
      );
      return;
    }
    const { subdomain } = req.params;
    const { base_url } = req.body;
    const saneDomain = domain_sanitize(subdomain);

    await db.runWithTenant(saneDomain, async () => {
      await getState().setConfig("base_url", base_url);
    });
    res.redirect(`/tenant/info/${text(subdomain)}`);
  })
);
/**
 * Execute Delete of tenant
 */
router.post(
  "/delete/:sub",
  setTenant,
  isAdmin,
  error_catcher(async (req, res) => {
    if (
      !db.is_it_multi_tenant() ||
      db.getTenantSchema() !== db.connectObj.default_schema
    ) {
      res.sendWrap(
        req.__("Create application"),
        req.__("Multi-tenancy not enabled")
      );
      return;
    }
    const { sub } = req.params;

    await deleteTenant(sub);
    res.redirect(`/tenant/list`);
  })
);