Source: Router/orgaSettings/service.js

/**
 * OrgaSettings Service
 *
 * Manages per-organisation settings stored in Vault:
 *  - Domain mappings  (orgas/data/{orgId}/domains)
 *  - App registry     (orgas/data/{orgId}/apps)
 *  - Mail/SMTP config (orgas/data/{orgId}/mail)
 *
 * Vault path convention (KV v2):
 *   orgas/data/{orgId}/domains  → { "subdomain": "internal", "custom.tld": "external", … }
 *   orgas/data/{orgId}/apps     → { [appId]: { domain, roles, title, description?, port? } }
 *   orgas/data/{orgId}/mail     → { host, port, user, password, userName, from, secure }
 */

import { getSecretsFromVault, saveSecretsToVault } from '@commtool/vault-secrets';
import { errorLoggerRead }      from '../../utils/requestLogger.js';
import { myMinioClient, PUBLIC_BUCKET, DATA_BUCKET, publicObjectUrl } from '../../utils/s3Client.js';
import sharp from 'sharp';

// ── Generic Vault section helpers ─────────────────────────────────────────────

/**
 * Load a named section for one organisation from Vault.
 * Returns `null` on missing key or error.
 * @param {string} orgId
 * @param {string} section  e.g. 'domains', 'apps', 'mail'
 * @returns {Promise<object|null>}
 */
async function getOrgSection(orgId, section) {
    try {
        return await getSecretsFromVault(`orgas/data/${orgId}/${section}`) ?? null;
    } catch (e) {
        errorLoggerRead(e);
        return null;
    }
}

/**
 * Persist a named section for one organisation in Vault.
 * @param {string} orgId
 * @param {string} section
 * @param {object} data
 */
async function saveOrgSection(orgId, section, data) {
    await saveSecretsToVault( data, `orgas/data/${orgId}/${section}`);
}

// ── Domain validation ─────────────────────────────────────────────────────────

/** Subdomain prefixes blocked for organisation use */
const RESERVED_PREFIXES = new Set([
    'admin', 'api', 'auth', 'vault', 'www', 'mail', 'smtp', 'ns', 'ns1', 'ns2',
    'ftp', 'ssh', 'vpn', 'git', 'registry', 'cdn', 'app', 'dev', 'test',
    'staging', 'prod', 'support', 'help', 'status', 'monitor', 'ops',
]);

/** internal domain: lowercase alphanum + hyphens, no leading/trailing hyphen */
const INTERNAL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;

/** external domain: at least two labels, valid TLD */
const EXTERNAL_RE = /^([a-z0-9][a-z0-9-]{0,61}[a-z0-9]\.)+[a-z]{2,}$/;

function validateSingleDomain(domain, type) {
    if (type === 'internal') {
        if (!INTERNAL_RE.test(domain))
            return 'Only lowercase letters, digits and hyphens allowed; may not start or end with a hyphen.';
        if (RESERVED_PREFIXES.has(domain))
            return `"${domain}" is a reserved system name.`;
    } else if (type === 'external') {
        if (!EXTERNAL_RE.test(domain))
            return 'Not a valid domain name (e.g. myclub.com).';
    } else {
        return `Unknown domain type "${type}". Use "internal" or "external".`;
    }
    return null; // valid
}

/**
 * Detect conflicts between a new domain and all existing domains across other orgs.
 *
 * Conflict rules:
 *  - Same type + same value → direct conflict
 *  - internal "foo" vs external "foo.com" → prefix conflict
 */
function findConflict(domain, type, allOrgDomains, currentOrgId) {
    for (const [orgId, domains] of Object.entries(allOrgDomains)) {
        if (orgId === currentOrgId) continue;
        for (const [existingDomain, existingType] of Object.entries(domains)) {
            if (type === existingType && domain === existingDomain)
                return orgId;

            // internal "foo" ↔ external "foo.*"
            if (type === 'internal' && existingType === 'external') {
                if (existingDomain.split('.')[0] === domain) return orgId;
            }
            if (type === 'external' && existingType === 'internal') {
                if (domain.split('.')[0] === existingDomain) return orgId;
            }
        }
    }
    return null;
}

// ── Public API ────────────────────────────────────────────────────────────────

/**
 * Load all domain maps for every organisation (for conflict checking).
 * @returns {Promise<Record<string, Record<string, string>>>}
 */
export async function getAllOrgDomains() {
    const orgsList = await getSecretsFromVault('orgas/data', { list: true }) ?? [];
    /** @type {Record<string, Record<string, string>>} */
    const result = {};

    await Promise.all(
        orgsList.map(async (raw) => {
            const orgId = raw.replace(/\/$/, '');
            try {
                const domains = await getSecretsFromVault(`orgas/data/${orgId}/domains`);
                if (domains && Object.keys(domains).length > 0)
                    result[orgId] = domains;
            } catch {
                // org has no domains configured – skip silently
            }
        })
    );
    return result;
}

/**
 * Load domain settings for one organisation.
 * @param {string} orgId
 * @returns {Promise<Record<string, string>>}
 */
export async function getDomains(orgId) {
    return /** @type {Record<string, string>} */ (await getOrgSection(orgId, 'domains') ?? {});
}

/**
 * Validate a proposed domains object.
 * @param {Record<string, string>} newDomains
 * @param {string} currentOrgId
 * @returns {Promise<{ domain: string; error: string }[]>} Array of validation errors (empty = OK)
 */
export async function validateDomains(newDomains, currentOrgId) {
    const errors = [];

    // Basic per-domain checks first (fast, no Vault round-trip needed yet)
    for (const [domain, type] of Object.entries(newDomains)) {
        const err = validateSingleDomain(domain, type);
        if (err) errors.push({ domain, error: err });
    }

    if (errors.length > 0) return errors; // don't bother fetching remote data

    // Conflict check against all other organisations
    const allOrgDomains = await getAllOrgDomains();
    for (const [domain, type] of Object.entries(newDomains)) {
        const conflictOrg = findConflict(domain, type, allOrgDomains, currentOrgId);
        if (conflictOrg)
            errors.push({ domain, error: 'Domain is already claimed by another organisation.' });
    }
    return errors;
}

/**
 * Persist domain settings for one organisation.
 * @param {string} orgId
 * @param {Record<string, string>} domains
 */
export async function saveDomains(orgId, domains) {
    await saveOrgSection(orgId, 'domains', domains);
}

// ── App settings ──────────────────────────────────────────────────────────────

/**
 * @typedef {Object} AppEntry
 * @property {string} domain       - The app's URL or hostname
 * @property {string[]} roles      - Keycloak roles that grant access
 * @property {string} title        - Human-readable app name
 * @property {string} [description]
 * @property {number} [port]
 */

/**
 * Load the app registry for one organisation.
 * @param {string} orgId
 * @returns {Promise<Record<string, AppEntry>>}
 */
export async function getApps(orgId) {
    return /** @type {Record<string, AppEntry>} */ (await getOrgSection(orgId, 'apps') ?? {});
}

/**
 * Persist the app registry for one organisation.
 * @param {string} orgId
 * @param {Record<string, AppEntry>} apps
 */
export async function saveApps(orgId, apps) {
    await saveOrgSection(orgId, 'apps', apps);
}

/** MIME type → file extension map for accepted icon formats */
const ICON_EXT = {
    'image/png':     'png',
    'image/jpeg':    'jpg',
    'image/svg+xml': 'svg',
    'image/gif':     'gif',
    'image/webp':    'webp',
};

/** Standard PWA icon variants written to the data bucket under manifests path */
const PWA_ICON_SIZES = [
    { name: 'icon-192.png',          size: 192 },
    { name: 'icon-512.png',          size: 512 },
    { name: 'icon-192-maskable.png', size: 192 },
    { name: 'icon-512-maskable.png', size: 512 },
    { name: 'favicon.png',           size: 32  },
];

/** Put a Buffer into MinIO */
function minioput(bucket, key, buffer, mimeType) {
    return new Promise((resolve, reject) => {
        myMinioClient.putObject(bucket, key, buffer, buffer.length, { 'Content-Type': mimeType },
            (err) => { if (err) reject(err); else resolve(); }
        );
    });
}

/**
 * Upload an app icon:
 *  1. Stores the original in the public bucket (URL saved in Vault apps[appId].icon).
 *  2. Uses sharp to generate all standard PWA sizes and writes them to the data
 *     bucket at `{orgId}/manifests/{appId}/{iconName}` — the exact paths the
 *     static server looks up, so no redirect is needed after this.
 *
 * @param {string} orgId
 * @param {string} appId
 * @param {import('stream').Readable} fileStream
 * @param {string} mimeType
 * @returns {Promise<string>} The public icon URL
 */
export async function uploadAppIcon(orgId, appId, fileStream, mimeType) {
    const ext = ICON_EXT[mimeType];
    if (!ext) throw new Error(`Unsupported image type: ${mimeType}`);

    // Buffer the stream once — needed for both original upload and sharp resizing
    const chunks = [];
    for await (const chunk of fileStream) chunks.push(chunk);
    const inputBuffer = Buffer.concat(chunks);

    // ── 1. Store original in public bucket ────────────────────────────────────
    const originalKey = `${orgId}/icons/${appId}-${Date.now()}.${ext}`;
    await minioput(PUBLIC_BUCKET, originalKey, inputBuffer, mimeType);
    const iconUrl = publicObjectUrl(originalKey);

    // ── 2. Generate and store all PWA icon sizes in data bucket ───────────────
    const baseImage = sharp(inputBuffer);
    await Promise.all(
        PWA_ICON_SIZES.map(async ({ name, size }) => {
            const resized = await baseImage.clone().resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer();
            const key = `${orgId}/manifests/${appId}/${name}`;
            await minioput(DATA_BUCKET, key, resized, 'image/png');
        })
    );

    // ── 3. Persist URL in Vault ───────────────────────────────────────────────
    const apps = await getOrgSection(orgId, 'apps') ?? {};
    if (!apps[appId]) throw new Error(`App "${appId}" not found for this organisation.`);

    // Delete old original from public bucket (best-effort)
    const oldIconUrl = apps[appId].icon;
    if (oldIconUrl) {
        try {
            const oldKey = oldIconUrl.replace(
                `${process.env.S3publicBaseUrl ?? `https://${process.env.S3endPoint}:${process.env.S3port}`}/${PUBLIC_BUCKET}/`, ''
            );
            await myMinioClient.removeObject(PUBLIC_BUCKET, oldKey);
        } catch { /* non-fatal */ }
    }

    apps[appId] = { ...apps[appId], icon: iconUrl };
    await saveOrgSection(orgId, 'apps', apps);

    return iconUrl;
}

// ── Mail settings ─────────────────────────────────────────────────────────────

const MASKED = '••••••••';

/**
 * Load mail/SMTP settings for one organisation.
 * The password is masked before being returned.
 * @param {string} orgId
 * @returns {Promise<object|null>}
 */
export async function getMailSettings(orgId) {
    const settings = await getOrgSection(orgId, 'mail');
    if (!settings) return null;
    return {
        ...settings,
        password: settings.password ? MASKED : '',
    };
}

/**
 * Persist mail/SMTP settings for one organisation.
 * If the password is the mask sentinel, the existing password is preserved.
 * @param {string} orgId
 * @param {object} settings
 */
export async function saveMailSettings(orgId, settings) {
    let dataToSave = { ...settings };

    if (dataToSave.password === MASKED) {
        // Password not changed – keep existing value from Vault
        const existing = await getOrgSection(orgId, 'mail') ?? {};
        dataToSave.password = existing.password ?? '';
    }

    await saveOrgSection(orgId, 'mail', dataToSave);
}