import apps from '../apps.js';
import validator from '../validator.js';
import appstore from '../appstore.js';
import assert from 'node:assert';
import AuditSource from '../auditsource.js';
import backups from '../backups.js';
import backupSites from '../backupsites.js';
import BoxError from '../boxerror.js';
import community from '../community.js';
import constants from '../constants.js';
import logger from '../logger.js';
import { HttpError } from '@cloudron/connect-lastmile';
import { HttpSuccess } from '@cloudron/connect-lastmile';
import metrics from '../metrics.js';
import safe from 'safetydance';
import tasks from '../tasks.js';
import updater from '../updater.js';
import users from '../users.js';
import { getImageContentType } from '../image-content-type.js';
import WebSocket from 'ws';

const { log } = logger('routes/apps');


async function load(req, res, next) {
    assert.strictEqual(typeof req.params.id, 'string');

    const [error, result] = await safe(apps.get(req.params.id));
    if (error) return next(BoxError.toHttpError(error));
    if (!result) return next(new HttpError(404, 'App not found'));

    req.resources.app = result;

    next();
}

function getApp(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');

    const accessLevel = apps.accessLevel(req.resources.app, req.user);
    const result = apps.pickFields(req.resources.app, accessLevel);
    result.accessLevel = accessLevel;

    next(new HttpSuccess(200, result));
}

async function listByUser(req, res, next) {
    assert.strictEqual(typeof req.user, 'object');

    const [error, results] = await safe(apps.listByUser(req.user));
    if (error) return next(BoxError.toHttpError(error));

    const filteredResult = results.map(app => {
        const accessLevel = apps.accessLevel(app, req.user);
        const result = apps.pickFields(app, accessLevel);
        result.accessLevel = accessLevel;
        return result;
    });

    next(new HttpSuccess(200, { apps: filteredResult }));
}

async function getAppIcon(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');

    const original = typeof req.query.original === 'string' ? (req.query.original === '1' || req.query.original === 'true') : false;

    const [error, icon] = await safe(apps.getIcon(req.resources.app, { original }));
    if (error) return next(BoxError.toHttpError(error));

    res.set('Content-Type', getImageContentType(icon)).send(icon);
}

async function install(req, res, next) {
    assert(typeof req.body === 'object' || typeof req.fields === 'object');

    const data = req.body || req.fields;

    // atleast one
    if ('manifest' in data && typeof data.manifest !== 'object') return next(new HttpError(400, 'manifest must be an object'));
    if ('appStoreId' in data && typeof data.appStoreId !== 'string') return next(new HttpError(400, 'appStoreId must be a string'));
    if ('versionsUrl' in data && typeof data.versionsUrl !== 'string') return next(new HttpError(400, 'versionsUrl must be a string'));
    if (!data.manifest && !data.appStoreId && !data.versionsUrl) return next(new HttpError(400, 'appStoreId, versionsUrl, or manifest is required'));

    // required
    if (typeof data.subdomain !== 'string') return next(new HttpError(400, 'subdomain is required'));
    if (typeof data.domain !== 'string') return next(new HttpError(400, 'domain is required'));
    if (typeof data.accessRestriction !== 'object') return next(new HttpError(400, 'accessRestriction is required'));

    // optional
    if (('ports' in data) && typeof data.ports !== 'object') return next(new HttpError(400, 'ports must be an object'));
    if ('icon' in data && typeof data.icon !== 'string') return next(new HttpError(400, 'icon is not a string'));

    if ('label' in data && typeof data.label !== 'string') return next(new HttpError(400, 'label must be a string'));

    if ('memoryLimit' in data && typeof data.memoryLimit !== 'number') return next(new HttpError(400, 'memoryLimit is not a number'));

    if ('sso' in data && typeof data.sso !== 'boolean') return next(new HttpError(400, 'sso must be a boolean'));
    if ('enableBackup' in data && typeof data.enableBackup !== 'boolean') return next(new HttpError(400, 'enableBackup must be a boolean'));
    if ('enableAutomaticUpdate' in data && typeof data.enableAutomaticUpdate !== 'boolean') return next(new HttpError(400, 'enableAutomaticUpdate must be a boolean'));

    if (('debugMode' in data) && typeof data.debugMode !== 'object') return next(new HttpError(400, 'debugMode must be an object'));

    if ('secondaryDomains' in data) {
        if (!data.secondaryDomains || typeof data.secondaryDomains !== 'object') return next(new HttpError(400, 'secondaryDomains must be an object'));
        if (Object.keys(data.secondaryDomains).some(function (key) { return typeof data.secondaryDomains[key].domain !== 'string' || typeof data.secondaryDomains[key].subdomain !== 'string'; })) return next(new HttpError(400, 'secondaryDomain object must contain domain and subdomain strings'));
    }

    if ('redirectDomains' in data) {
        if (!Array.isArray(data.redirectDomains)) return next(new HttpError(400, 'redirectDomains must be an array'));
        if (data.redirectDomains.some(function (d) { return (typeof d.domain !== 'string' || typeof d.subdomain !== 'string'); })) return next(new HttpError(400, 'redirectDomains array must contain objects with domain and subdomain strings'));
    }

    if ('aliasDomains' in data) {
        if (!Array.isArray(data.aliasDomains)) return next(new HttpError(400, 'aliasDomains must be an array'));
        if (data.aliasDomains.some(function (d) { return (typeof d.domain !== 'string' || typeof d.subdomain !== 'string'); })) return next(new HttpError(400, 'aliasDomains array must contain objects with domain and subdomain strings'));
    }

    if ('env' in data) {
        if (!data.env || typeof data.env !== 'object') return next(new HttpError(400, 'env must be an object'));
        if (Object.keys(data.env).some(function (key) { return typeof data.env[key] !== 'string'; })) return next(new HttpError(400, 'env must contain values as strings'));
    }

    if ('overwriteDns' in data && typeof data.overwriteDns !== 'boolean') return next(new HttpError(400, 'overwriteDns must be boolean'));
    if ('skipDnsSetup' in data && typeof data.skipDnsSetup !== 'boolean') return next(new HttpError(400, 'skipDnsSetup must be boolean'));
    if ('enableMailbox' in data && typeof data.enableMailbox !== 'boolean') return next(new HttpError(400, 'enableMailbox must be boolean'));

    if ('enableTurn' in data && typeof data.enableTurn !== 'boolean') return next(new HttpError(400, 'enableTurn must be boolean'));
    if ('enableRedis' in data && typeof data.enableRedis !== 'boolean') return next(new HttpError(400, 'enableRedis must be boolean'));

    if ('cpuQuota' in data && data.cpuQuota !== 'number') return next(new HttpError(400, 'cpuQuota is not a number'));
    if ('operators' in data && typeof data.operators !== 'object') return next(new HttpError(400, 'operators must be an object'));

    let error, result;
    if (data.versionsUrl) {
        [error, result] = await safe(community.downloadManifest(data.versionsUrl));
        data.manifest = result.manifest;
        data.versionsUrl = result.versionsUrl; // without version
    } else {
        [error, result] = await safe(appstore.downloadManifest(data.appStoreId, data.manifest));
        data.manifest = result.manifest;
        data.appStoreId = result.appStoreId; // without version
    }
    if (error) return next(BoxError.toHttpError(error));

    if (result.appStoreId === constants.PROXY_APP_APPSTORE_ID && typeof data.upstreamUri !== 'string') return next(new HttpError(400, 'upstreamUri must be a non empty string'));

    if (safe.query(result.manifest, 'addons.docker') && req.user.role !== users.ROLE_OWNER) return next(new HttpError(403, '"owner" role is required to install app with docker addon'));

    data.sourceArchiveFilePath = req.files && req.files.sourceArchive?.path || null;

    [error, result] = await safe(apps.install(data, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { id: result.id, taskId: result.taskId }));
}

async function setAccessRestriction(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (typeof req.body.accessRestriction !== 'object') return next(new HttpError(400, 'accessRestriction must be an object'));

    const [error] = await safe(apps.setAccessRestriction(req.resources.app, req.body.accessRestriction, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(200, {}));
}

async function setOperators(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (typeof req.body.operators !== 'object') return next(new HttpError(400, 'operators must be an object'));

    const [error] = await safe(apps.setOperators(req.resources.app, req.body.operators, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(200, {}));
}

async function setCrontab(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (req.body.crontab !== null && typeof req.body.crontab !== 'string') return next(new HttpError(400, 'crontab must be a string'));

    const [error] = await safe(apps.setCrontab(req.resources.app, req.body.crontab, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(200, {}));
}

async function setLabel(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (typeof req.body.label !== 'string') return next(new HttpError(400, 'label must be a string'));

    const [error] = await safe(apps.setLabel(req.resources.app, req.body.label, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(200, {}));
}

async function setTags(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (!Array.isArray(req.body.tags)) return next(new HttpError(400, 'tags must be an array'));
    if (req.body.tags.some((t) => typeof t !== 'string')) return next(new HttpError(400, 'tags array must contain strings'));

    const [error] = await safe(apps.setTags(req.resources.app, req.body.tags, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(200, {}));
}

async function setNotes(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (typeof req.body.notes !== 'string') return next(new HttpError(400, 'notes must be a string'));

    const [error] = await safe(apps.setNotes(req.resources.app, req.body.notes, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(200, {}));
}

async function setChecklistItem(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (typeof req.body.done !== 'boolean') return next(new HttpError(400, 'done must be a boolean'));

    const [error] = await safe(apps.setChecklistItem(req.resources.app, req.params.key, req.body.done, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, {}));
}

async function setIcon(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (req.body.icon !== null && typeof req.body.icon !== 'string') return next(new HttpError(400, 'icon is null or a base-64 image string'));

    const [error] = await safe(apps.setIcon(req.resources.app, req.body.icon || null /* empty string means null */, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(200, {}));
}

async function setMemoryLimit(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (typeof req.body.memoryLimit !== 'number') return next(new HttpError(400, 'memoryLimit is not a number'));

    const [error, result] = await safe(apps.setMemoryLimit(req.resources.app, req.body.memoryLimit, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId }));
}

async function setCpuQuota(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (typeof req.body.cpuQuota !== 'number') return next(new HttpError(400, 'cpuQuota is not a number'));

    const [error, result] = await safe(apps.setCpuQuota(req.resources.app, req.body.cpuQuota, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId }));
}

async function setAutomaticBackup(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (typeof req.body.enable !== 'boolean') return next(new HttpError(400, 'enable must be a boolean'));

    const [error] = await safe(apps.setAutomaticBackup(req.resources.app, req.body.enable, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(200, {}));
}

async function setAutomaticUpdate(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (typeof req.body.enable !== 'boolean') return next(new HttpError(400, 'enable must be a boolean'));

    const [error] = await safe(apps.setAutomaticUpdate(req.resources.app, req.body.enable, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(200, {}));
}

async function setReverseProxyConfig(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (req.body.robotsTxt !== null && typeof req.body.robotsTxt !== 'string') return next(new HttpError(400, 'robotsTxt is not a string'));

    if (req.body.csp !== null && typeof req.body.csp !== 'string') return next(new HttpError(400, 'csp is not a string'));

    if (typeof req.body.hstsPreload !== 'boolean') return next(new HttpError(400, 'hstsPreload must be a boolean'));

    const [error] = await safe(apps.setReverseProxyConfig(req.resources.app, req.body, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(200, {}));
}

async function setCertificate(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (typeof req.body.subdomain !== 'string') return next(new HttpError(400, 'subdomain must be string')); // subdomain may be an empty string
    if (!req.body.domain) return next(new HttpError(400, 'domain is required'));
    if (typeof req.body.domain !== 'string') return next(new HttpError(400, 'domain must be string'));

    if (req.body.key !== null && typeof req.body.cert !== 'string') return next(new HttpError(400, 'cert must be a string'));
    if (req.body.cert !== null && typeof req.body.key !== 'string') return next(new HttpError(400, 'key must be a string'));
    if (req.body.cert && !req.body.key) return next(new HttpError(400, 'key must be provided'));
    if (!req.body.cert && req.body.key) return next(new HttpError(400, 'cert must be provided'));

    const [error] = await safe(apps.setCertificate(req.resources.app, req.body, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(200, {}));
}

async function setEnvironment(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (!req.body.env || typeof req.body.env !== 'object') return next(new HttpError(400, 'env must be an object'));
    if (Object.keys(req.body.env).some((key) => typeof req.body.env[key] !== 'string')) return next(new HttpError(400, 'env must contain values as strings'));

    const [error, result] = await safe(apps.setEnvironment(req.resources.app, req.body.env, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId }));
}

async function setDebugMode(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (req.body.debugMode !== null && typeof req.body.debugMode !== 'object') return next(new HttpError(400, 'debugMode must be an object'));

    const [error, result] = await safe(apps.setDebugMode(req.resources.app, req.body.debugMode, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId }));
}

async function setMailbox(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (typeof req.body.enable !== 'boolean') return next(new HttpError(400, 'enable must be a boolean'));
    if (req.body.enable) {
        if (req.body.mailboxName !== null && typeof req.body.mailboxName !== 'string') return next(new HttpError(400, 'mailboxName must be a string'));
        if (typeof req.body.mailboxDomain !== 'string') return next(new HttpError(400, 'mailboxDomain must be a string'));
        if ('mailboxDisplayName' in req.body && typeof req.body.mailboxDisplayName !== 'string') return next(new HttpError(400, 'mailboxDisplayName must be a string'));
    }

    const [error, result] = await safe(apps.setMailbox(req.resources.app, req.body, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId }));
}

async function setInbox(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (typeof req.body.enable !== 'boolean') return next(new HttpError(400, 'enable must be a boolean'));
    if (req.body.enable) {
        if (typeof req.body.inboxName !== 'string') return next(new HttpError(400, 'inboxName must be a string'));
        if (typeof req.body.inboxDomain !== 'string') return next(new HttpError(400, 'inboxDomain must be a string'));
    }

    const [error, result] = await safe(apps.setInbox(req.resources.app, req.body, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId }));
}

async function setTurn(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (typeof req.body.enable !== 'boolean') return next(new HttpError(400, 'enable must be a boolean'));

    const [error, result] = await safe(apps.setTurn(req.resources.app, req.body.enable, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId }));
}

async function setRedis(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (typeof req.body.enable !== 'boolean') return next(new HttpError(400, 'enable must be a boolean'));

    const [error, result] = await safe(apps.setRedis(req.resources.app, req.body.enable, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId }));
}

async function setLocation(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (typeof req.body.subdomain !== 'string') return next(new HttpError(400, 'subdomain must be string')); // subdomain may be an empty string
    if (typeof req.body.domain !== 'string') return next(new HttpError(400, 'domain must be string'));

    if ('ports' in req.body && typeof req.body.ports !== 'object') return next(new HttpError(400, 'ports must be an object'));

    if ('secondaryDomains' in req.body) {
        if (!req.body.secondaryDomains || typeof req.body.secondaryDomains !== 'object') return next(new HttpError(400, 'secondaryDomains must be an object'));
        if (Object.keys(req.body.secondaryDomains).some(function (key) { return typeof req.body.secondaryDomains[key].domain !== 'string' || typeof req.body.secondaryDomains[key].subdomain !== 'string'; })) return next(new HttpError(400, 'secondaryDomain object must contain domain and subdomain strings'));
    }

    if ('redirectDomains' in req.body) {
        if (!Array.isArray(req.body.redirectDomains)) return next(new HttpError(400, 'redirectDomains must be an array'));
        if (req.body.redirectDomains.some(function (d) { return (typeof d.domain !== 'string' || typeof d.subdomain !== 'string'); })) return next(new HttpError(400, 'redirectDomains array must contain objects with domain and subdomain strings'));
    }

    if ('aliasDomains' in req.body) {
        if (!Array.isArray(req.body.aliasDomains)) return next(new HttpError(400, 'aliasDomains must be an array'));
        if (req.body.aliasDomains.some(function (d) { return (typeof d.domain !== 'string' || typeof d.subdomain !== 'string'); })) return next(new HttpError(400, 'aliasDomains array must contain objects with domain and subdomain strings'));
    }

    if ('overwriteDns' in req.body && typeof req.body.overwriteDns !== 'boolean') return next(new HttpError(400, 'overwriteDns must be boolean'));
    if ('skipDnsSetup' in req.body && typeof req.body.skipDnsSetup !== 'boolean') return next(new HttpError(400, 'skipDnsSetup must be boolean'));

    const [error, result] = await safe(apps.setLocation(req.resources.app, req.body, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId }));
}

async function setStorage(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    const { storageVolumeId, storageVolumePrefix } = req.body;

    if (storageVolumeId !== null) {
        if (typeof storageVolumeId !== 'string') return next(new HttpError(400, 'storageVolumeId must be a string'));
        if (typeof storageVolumePrefix !== 'string') return next(new HttpError(400, 'storageVolumePrefix must be a string'));
    }

    const [error, result] = await safe(apps.setStorage(req.resources.app, storageVolumeId, storageVolumePrefix, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId }));
}

async function repair(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    const data = req.body;

    if ('manifest' in data) {
        if (!data.manifest || typeof data.manifest !== 'object') return next(new HttpError(400, 'manifest must be an object'));

        if (safe.query(data.manifest, 'addons.docker') && req.user.role !== users.ROLE_OWNER) return next(new HttpError(403, '"owner" role is required to repair app with docker addon'));
    }

    if ('dockerImage' in data) {
        if (!data.dockerImage || typeof data.dockerImage !== 'string') return next(new HttpError(400, 'dockerImage must be a string'));
    }

    const [error, result] = await safe(apps.repair(req.resources.app, data, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId }));
}

async function restore(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    const data = req.body;

    if (!data.backupId || typeof data.backupId !== 'string') return next(new HttpError(400, 'backupId must be non-empty string'));

    const [error, result] = await safe(apps.restore(req.resources.app, data.backupId, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId }));
}

async function importApp(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    const data = req.body;

    if ('remotePath' in data) {
        if (typeof data.remotePath !== 'string' || !data.remotePath) return next(new HttpError(400, 'remotePath must be non-empty string'));
        if (typeof data.format !== 'string') return next(new HttpError(400, 'format must be string'));
        if (typeof data.provider !== 'string') return next(new HttpError(400, 'provider is required'));
        if (typeof data.config !== 'object' || !data.config) return next(new HttpError(400, 'config must be an object'));

        const config = req.body.config;

        if ('encryptionPassword' in config && typeof config.encryptionPassword !== 'string') return next(new HttpError(400, 'encryptionPassword must be a string'));
        if ('encryptedFilenames' in config && typeof config.encryptedFilenames !== 'boolean') return next(new HttpError(400, 'encryptedFilenames must be a boolean'));

        // testing backup config can take sometime
        req.clearTimeout();
    } else {
        if (typeof data.inPlace !== 'boolean') return next(new HttpError(400, 'remotePath or inPlace is required'));
    }

    const [error, result] = await safe(apps.importApp(req.resources.app, data, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId }));
}

async function exportApp(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (typeof req.body.backupSiteId !== 'string') return next(new HttpError(400, 'backupSiteId must be a string'));

    const [error, result] = await safe(apps.exportApp(req.resources.app, req.body.backupSiteId, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId }));
}

async function clone(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    const data = req.body;

    if (typeof data.backupId !== 'string') return next(new HttpError(400, 'backupId must be a string'));
    if (typeof data.subdomain !== 'string') return next(new HttpError(400, 'subdomain is required'));
    if (typeof data.domain !== 'string') return next(new HttpError(400, 'domain is required'));
    if (('ports' in data) && typeof data.ports !== 'object') return next(new HttpError(400, 'ports must be an object'));

    if ('secondaryDomains' in data) {
        if (!data.secondaryDomains || typeof data.secondaryDomains !== 'object') return next(new HttpError(400, 'secondaryDomains must be an object'));
        if (Object.keys(data.secondaryDomains).some(function (key) { return typeof data.secondaryDomains[key].domain !== 'string' || typeof data.secondaryDomains[key].subdomain !== 'string'; })) return next(new HttpError(400, 'secondaryDomain object must contain domain and subdomain strings'));
    }

    if ('overwriteDns' in req.body && typeof req.body.overwriteDns !== 'boolean') return next(new HttpError(400, 'overwriteDns must be boolean'));
    if ('skipDnsSetup' in req.body && typeof req.body.skipDnsSetup !== 'boolean') return next(new HttpError(400, 'skipDnsSetup must be boolean'));

    const [error, result] = await safe(apps.clone(req.resources.app, data, req.user, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(201, { id: result.id, taskId: result.taskId }));
}

async function backup(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');

    if (typeof req.body.backupSiteId !== 'string') return next(new HttpError(400, 'backupSiteId must be a string'));

    const [error, result] = await safe(apps.backup(req.resources.app, req.body.backupSiteId, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId }));
}

async function uninstall(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');

    const [error, result] = await safe(apps.uninstall(req.resources.app, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId }));
}

async function archive(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');

    if (typeof req.body.backupId !== 'string') return next(new HttpError(400, 'backupId must be a string'));

    const [error, result] = await safe(apps.archive(req.resources.app, req.body.backupId, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId, id: result.id }));
}

async function start(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');

    const [error, result] = await safe(apps.start(req.resources.app, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId }));
}

async function stop(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');

    const [error, result] = await safe(apps.stop(req.resources.app, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId }));
}

async function restart(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');

    const [error, result] = await safe(apps.restart(req.resources.app, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId }));
}

async function update(req, res, next) {
    assert(typeof req.body === 'object' || typeof req.fields === 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    const data = req.body || req.fields;

    // atleast one
    if ('manifest' in data && typeof data.manifest !== 'object') return next(new HttpError(400, 'manifest must be an object'));
    if ('appStoreId' in data && typeof data.appStoreId !== 'string') return next(new HttpError(400, 'appStoreId must be a string'));
    if ('versionsUrl' in data && typeof data.versionsUrl !== 'string') return next(new HttpError(400, 'versionsUrl must be a string'));
    if (!data.manifest && !data.appStoreId && !data.versionsUrl) return next(new HttpError(400, 'appStoreId, versionsUrl, or manifest is required'));

    if ('skipBackup' in data && typeof data.skipBackup !== 'boolean') return next(new HttpError(400, 'skipBackup must be a boolean'));
    if ('force' in data && typeof data.force !== 'boolean') return next(new HttpError(400, 'force must be a boolean'));

    let error, result;
    if (data.versionsUrl) {
        [error, result] = await safe(community.downloadManifest(data.versionsUrl));
        data.manifest = result.manifest;
        data.versionsUrl = result.versionsUrl; // without version
    } else {
        [error, result] = await safe(appstore.downloadManifest(data.appStoreId, data.manifest));
        data.manifest = result.manifest;
        data.appStoreId = result.appStoreId; // without version
    }
    if (error) return next(BoxError.toHttpError(error));

    if (safe.query(data.manifest, 'addons.docker') && req.user.role !== users.ROLE_OWNER) return next(new HttpError(403, '"owner" role is required to update app with docker addon'));

    data.sourceArchiveFilePath = req.files && req.files.sourceArchive?.path || null;

    [error, result] = await safe(apps.updateApp(req.resources.app, data, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId }));
}

// this route is for streaming logs
async function getLogStream(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');

    const lines = typeof req.query.lines === 'string' ? parseInt(req.query.lines, 10) : 10; // we ignore last-event-id
    if (isNaN(lines)) return next(new HttpError(400, 'lines must be a valid number'));

    if (req.headers.accept !== 'text/event-stream') return next(new HttpError(400, 'This API call requires EventStream'));

    const options = {
        lines: lines,
        follow: true,
        format: 'json'
    };

    const [error, logStream] = await safe(apps.getLogs(req.resources.app, options));
    if (error) return next(BoxError.toHttpError(error));

    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive',
        'X-Accel-Buffering': 'no', // disable nginx buffering
        'Access-Control-Allow-Origin': '*'
    });
    res.write('retry: 3000\n');
    res.on('close', () => logStream.destroy());
    logStream.on('data', function (data) {
        const obj = JSON.parse(data);
        const sse = `data: ${JSON.stringify(obj)}\n\n`;
        res.write(sse);
    });
    logStream.on('end', res.end.bind(res));
    logStream.on('error', res.end.bind(res, null));
}

async function getLogs(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');

    const lines = typeof req.query.lines === 'string' ? parseInt(req.query.lines, 10) : 10;
    if (isNaN(lines)) return next(new HttpError(400, 'lines must be a number'));

    const options = {
        lines,
        follow: false,
        format: typeof req.query.format === 'string' ? req.query.format : 'json'
    };

    const [error, logStream] = await safe(apps.getLogs(req.resources.app, options));
    if (error) return next(BoxError.toHttpError(error));

    res.writeHead(200, {
        'Content-Type': 'application/x-logs',
        'Content-Disposition': `attachment; filename="${req.resources.app.id}.log"`,
        'Cache-Control': 'no-cache',
        'X-Accel-Buffering': 'no' // disable nginx buffering
    });
    res.on('close', () => logStream.destroy());
    logStream.pipe(res);
}

function demuxStream(stream, stdin) {
    let header = null;

    stream.on('readable', function() {
        header = header || stream.read(4);

        while (header !== null) {
            const length = header.readUInt32BE(0);
            if (length === 0) {
                header = null;
                return stdin.end(); // EOF
            }

            const payload = stream.read(length);

            if (payload === null) break;
            stdin.write(payload);
            header = stream.read(4);
        }
    });
}

async function createExec(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');
    assert.strictEqual(typeof req.body, 'object');

    if ('cmd' in req.body) {
        if (!Array.isArray(req.body.cmd) || req.body.cmd.length < 1) return next(new HttpError(400, 'cmd must be array with atleast size 1'));
    }
    const cmd = req.body.cmd || null;

    if ('tty' in req.body && typeof req.body.tty !== 'boolean') return next(new HttpError(400, 'tty must be boolean'));
    const tty = !!req.body.tty;

    if ('lang' in req.body && typeof req.body.lang !== 'string') return next(new HttpError(400, 'lang must be a string'));

    if ('cwd' in req.body && typeof req.body.cwd !== 'string') return next(new HttpError(400, 'cwd must be a string'));

    if (safe.query(req.resources.app, 'manifest.addons.docker') && req.user.role !== users.ROLE_OWNER) return next(new HttpError(403, '"owner" role is requied to exec app with docker addon'));

    const [error, id] = await safe(apps.createExec(req.resources.app, { cmd, tty, lang: req.body.lang, cwd: req.body.cwd }));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(200, { id }));
}

async function startExec(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');
    assert.strictEqual(typeof req.params.execId, 'string');

    const columns = typeof req.query.columns === 'string' ? parseInt(req.query.columns, 10) : null;
    if (isNaN(columns)) return next(new HttpError(400, 'columns must be a number'));

    const rows = typeof req.query.rows === 'string' ? parseInt(req.query.rows, 10) : null;
    if (isNaN(rows)) return next(new HttpError(400, 'rows must be a number'));

    const tty = typeof req.query.tty === 'string' ? (req.query.tty === '1' || req.query.tty === 'true') : false;

    if (safe.query(req.resources.app, 'manifest.addons.docker') && req.user.role !== users.ROLE_OWNER) return next(new HttpError(403, '"owner" role is requied to exec app with docker addon'));

    // in a badly configured reverse proxy, we might be here without an upgrade
    if (req.headers['upgrade'] !== 'tcp') return next(new HttpError(404, 'exec requires TCP upgrade'));

    const [error, duplexStream] = await safe(apps.startExec(req.resources.app, req.params.execId, { rows, columns, tty }));
    if (error) return next(BoxError.toHttpError(error));

    req.clearTimeout();
    res.sendUpgradeHandshake();

    // When tty is disabled, the duplexStream has 2 separate streams. When enabled, it has stdout/stderr merged.
    duplexStream.pipe(res.socket);

    if (tty) {
        res.socket.pipe(duplexStream); // in tty mode, the client always waits for server to exit
    } else {
        demuxStream(res.socket, duplexStream);
        res.socket.on('error', function () { duplexStream.end(); });
        res.socket.on('end', function () { duplexStream.end(); });
    }
}

async function startExecWebSocket(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');
    assert.strictEqual(typeof req.params.execId, 'string');

    const columns = typeof req.query.columns === 'string' ? parseInt(req.query.columns, 10) : null;
    if (isNaN(columns)) return next(new HttpError(400, 'columns must be a number'));

    const rows = typeof req.query.rows === 'string' ? parseInt(req.query.rows, 10) : null;
    if (isNaN(rows)) return next(new HttpError(400, 'rows must be a number'));

    const tty = typeof req.query.tty === 'string' ? (req.query.tty === '1' || req.query.tty === 'true') : false;

    // in a badly configured reverse proxy, we might be here without an upgrade
    if (req.headers['upgrade'] !== 'websocket') return next(new HttpError(404, 'exec requires websocket'));

    const [error, duplexStream] = await safe(apps.startExec(req.resources.app, req.params.execId, { rows, columns, tty }));
    if (error) return next(BoxError.toHttpError(error));

    req.clearTimeout();

    res.handleUpgrade(function (ws) {
        duplexStream.on('end', function () { ws.close(); });
        duplexStream.on('close', function () { ws.close(); });
        duplexStream.on('error', function (streamError) {
            log('duplexStream error: %o', streamError);
        });
        duplexStream.on('data', function (data) {
            if (ws.readyState !== WebSocket.OPEN) return;
            ws.send(data.toString());
        });

        ws.on('error', function (wsError) {
            log('websocket error: %o', wsError);
        });
        ws.on('message', function (msg) {
            duplexStream.write(msg);
        });
        ws.on('close', function () {
            // Clean things up, if any?
        });
    });
}

async function getExec(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');
    assert.strictEqual(typeof req.params.execId, 'string');

    const [error, result] = await safe(apps.getExec(req.resources.app, req.params.execId));
    if (error) return next(BoxError.toHttpError(error));
    next(new HttpSuccess(200, result)); // { exitCode, running }
}

async function listBackups(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');

    const page = typeof req.query.page === 'string' ? parseInt(req.query.page) : 1;
    if (!page || page < 0) return next(new HttpError(400, 'page query param has to be a postive number'));

    const perPage = typeof req.query.per_page === 'string'? parseInt(req.query.per_page) : 25;
    if (!perPage || perPage < 0) return next(new HttpError(400, 'per_page query param has to be a postive number'));

    const [error, result] = await safe(apps.listBackups(req.resources.app, page, perPage));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(200, { backups: result.map(backups.removePrivateFields) }));
}

async function listBackupSites(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');

    const [error, result] = await safe(backupSites.list());
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(200, { backupSites: result.map(backupSites.removePrivateFields) }));
}

async function updateBackup(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');
    assert.strictEqual(typeof req.params.backupId, 'string');
    assert.strictEqual(typeof req.body, 'object');

    const { label, preserveSecs } = req.body;
    if (typeof label !== 'string') return next(new HttpError(400, 'label must be a string'));
    if (typeof preserveSecs !== 'number') return next(new HttpError(400, 'preserveSecs must be a number'));

    const [error] = await safe(apps.updateBackup(req.resources.app, req.params.backupId, { label, preserveSecs }));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(200, {}));
}

async function downloadBackup(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');
    assert.strictEqual(typeof req.params.backupId, 'string');

    const [error, result] = await safe(apps.getBackupDownloadStream(req.resources.app, req.params.backupId));
    if (error) return next(BoxError.toHttpError(error));

    res.attachment(result.filename);
    result.stream.pipe(res);
}

async function uploadFile(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');
    assert.strictEqual(typeof req.files, 'object');

    if (typeof req.query.file !== 'string' || !req.query.file) return next(new HttpError(400, 'file query argument must be provided'));
    if (!req.files.file) return next(new HttpError(400, 'file must be provided as multipart'));

    req.clearTimeout();

    const [error] = await safe(apps.uploadFile(req.resources.app, req.files.file.path, req.query.file));
    safe.fs.unlinkSync(req.files.file.path);

    if (error) return next(BoxError.toHttpError(error));
    next(new HttpSuccess(202, {}));
}

async function downloadFile(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');

    if (typeof req.query.file !== 'string' || !req.query.file) return next(new HttpError(400, 'file query argument must be provided'));

    req.clearTimeout();

    const [error, result] = await safe(apps.downloadFile(req.resources.app, req.query.file));
    if (error) return next(BoxError.toHttpError(error));

    const { stream, filename, size } = result;
    const headers = {
        'Content-Type': 'application/octet-stream',
        'Content-Disposition': `attachment; filename*=utf-8''${encodeURIComponent(filename)}` // RFC 2184 section 4
    };
    if (size) headers['Content-Length'] = size;

    res.writeHead(200, headers);

    stream.pipe(res);
}

async function setMounts(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (!Array.isArray(req.body.mounts)) return next(new HttpError(400, 'mounts should be an array'));
    for (const m of req.body.mounts) {
        if (!m || typeof m !== 'object') return next(new HttpError(400, 'mounts must be an object'));
        if (typeof m.volumeId !== 'string') return next(new HttpError(400, 'volumeId must be a string'));
        if (typeof m.readOnly !== 'boolean') return next(new HttpError(400, 'readOnly must be a boolean'));
    }

    const [error, result] = await safe(apps.setMounts(req.resources.app, req.body.mounts, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId }));
}

async function setDevices(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (typeof req.body.devices !== 'object') return next(new HttpError(400, 'devices should be an object'));

    const [error, result] = await safe(apps.setDevices(req.resources.app, req.body.devices, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(202, { taskId: result.taskId }));
}

async function setUpstreamUri(req, res, next) {
    assert.strictEqual(typeof req.body, 'object');
    assert.strictEqual(typeof req.resources.app, 'object');

    if (typeof req.body.upstreamUri !== 'string') return next(new HttpError(400, 'upstreamUri must be a string'));

    const [error] = await safe(apps.setUpstreamUri(req.resources.app, req.body.upstreamUri, AuditSource.fromRequest(req)));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(200, {}));
}

async function listEventlog(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');

    const page = typeof req.query.page === 'string' ? parseInt(req.query.page) : 1;
    if (!page || page < 0) return next(new HttpError(400, 'page query param has to be a postive number'));

    const perPage = typeof req.query.per_page === 'string'? parseInt(req.query.per_page) : 25;
    if (!perPage || perPage < 0) return next(new HttpError(400, 'per_page query param has to be a postive number'));

    if (req.query.from) {
        if (typeof req.query.from !== 'string') return next(new HttpError(400, 'from must be an ISO 8601 datetime string'));
        if (!validator.isIsoDate(req.query.from)) return next(new HttpError(400, 'from must be a valid ISO 8601 datetime string'));
    }

    if (req.query.to) {
        if (typeof req.query.to !== 'string') return next(new HttpError(400, 'to must be an ISO 8601 datetime string'));
        if (!validator.isIsoDate(req.query.to)) return next(new HttpError(400, 'to must be a valid ISO 8601 datetime string'));
    }

    const filter = {
        from: req.query.from ? new Date(req.query.from) : null,
        to: req.query.to ? new Date(req.query.to) : null
    };
    const [error, eventlogs] = await safe(apps.listEventlog(req.resources.app, filter, page, perPage));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(200, { eventlogs }));
}

async function checkUpdate(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');

    if (!req.resources.app.appStoreId && !req.resources.app.versionsUrl) return next(new HttpError(400, 'Custom apps have no updates'));

    // it can take a while sometimes to get all the app updates one by one
    req.clearTimeout();

    const [error, result] = await safe(updater.checkAppUpdate(req.resources.app, { stableOnly: false }));
    if (error) return next(BoxError.toHttpError(error));
    next(new HttpSuccess(200, { update: result }));
}

async function getTask(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');

    const [error, result] = await safe(apps.getTask(req.resources.app));
    if (error) return next(BoxError.toHttpError(error));
    if (result === null) return next(new HttpError(400, 'No active task'));

    next(new HttpSuccess(200, result));
}

async function getMetrics(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');

    if (typeof req.query.fromSecs !== 'string' || !parseInt(req.query.fromSecs)) return next(new HttpError(400, 'fromSecs must be a number'));
    if (typeof req.query.intervalSecs !== 'string' || !parseInt(req.query.intervalSecs)) return next(new HttpError(400, 'intervalSecs must be a number'));

    const fromSecs = parseInt(req.query.fromSecs);
    const intervalSecs = parseInt(req.query.intervalSecs);
    const noNullPoints = typeof req.query.noNullPoints === 'string' ? (req.query.noNullPoints === '1' || req.query.noNullPoints === 'true') : false;

    const [error, result] = await safe(metrics.get({ fromSecs, noNullPoints, intervalSecs, appIds: [req.resources.app.id] }));
    if (error) return next(new HttpError(500, error));

    next(new HttpSuccess(200, result));
}

async function getMetricStream(req, res, next) {
    if (req.headers.accept !== 'text/event-stream') return next(new HttpError(400, 'This API call requires EventStream'));

    const [error, metricStream] = await safe(metrics.getStream({ appIds: [req.resources.app.id] }));
    if (error) return next(BoxError.toHttpError(error));

    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive',
        'X-Accel-Buffering': 'no', // disable nginx buffering
        'Access-Control-Allow-Origin': '*'
    });
    res.write('retry: 3000\n');
    res.on('close', () => metricStream.destroy());
    metricStream.on('data', function (obj) { // objectMode stream
        const sse = `data: ${JSON.stringify(obj)}\n\n`;
        res.write(sse);
    });
    metricStream.on('end', res.end.bind(res));
    metricStream.on('error', res.end.bind(res, null));
}

function isAppTask(task, appId) {
    if (task.type === tasks.TASK_APP_BACKUP_PREFIX + appId) return true;
    if (task.type === tasks.TASK_APP && task.args[0] === appId) return true;
    return false;
}

async function loadTask(req, res, next) {
    assert.strictEqual(typeof req.params.taskId, 'string');
    assert.strictEqual(typeof req.resources.app, 'object');

    const [error, result] = await safe(tasks.get(req.params.taskId));
    if (error) return next(BoxError.toHttpError(error));
    if (!result) return next(new HttpError(404, 'Task not found'));
    if (!isAppTask(result, req.resources.app.id)) return next(new HttpError(404, 'Task not found'));

    req.resources.task = result;

    next();
}

async function listAppTasks(req, res, next) {
    assert.strictEqual(typeof req.resources.app, 'object');

    const [error, result] = await safe(tasks.list(1, 25, { prefix: tasks.TASK_APP_BACKUP_PREFIX + req.resources.app.id }));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(200, { tasks: result.map(tasks.removePrivateFields) }));
}

async function getAppTask(req, res, next) {
    assert.strictEqual(typeof req.resources.task, 'object');

    next(new HttpSuccess(200, tasks.removePrivateFields(req.resources.task)));
}

async function getAppTaskLogs(req, res, next) {
    assert.strictEqual(typeof req.resources.task, 'object');

    const lines = typeof req.query.lines === 'string' ? parseInt(req.query.lines, 10) : 10;
    if (isNaN(lines)) return next(new HttpError(400, 'lines must be a number'));

    const options = {
        lines: lines,
        follow: false,
        format: typeof req.query.format === 'string' ? req.query.format : 'json'
    };

    const [error, logStream] = await safe(tasks.getLogs(req.resources.task, options));
    if (error) return next(BoxError.toHttpError(error));

    res.writeHead(200, {
        'Content-Type': 'application/x-logs',
        'Content-Disposition': `attachment; filename="task-${req.resources.task.id}.log"`,
        'Cache-Control': 'no-cache',
        'X-Accel-Buffering': 'no'
    });
    res.on('close', () => logStream.destroy());
    logStream.pipe(res);
}

async function getAppTaskLogStream(req, res, next) {
    assert.strictEqual(typeof req.resources.task, 'object');

    const lines = typeof req.query.lines === 'string' ? parseInt(req.query.lines, 10) : 10;
    if (isNaN(lines)) return next(new HttpError(400, 'lines must be a valid number'));

    function sse(id, data) { return 'id: ' + id + '\ndata: ' + data + '\n\n'; }

    if (req.headers.accept !== 'text/event-stream') return next(new HttpError(400, 'This API call requires EventStream'));

    const options = {
        lines: lines,
        follow: true,
        format: 'json'
    };

    const [error, logStream] = await safe(tasks.getLogs(req.resources.task, options));
    if (error) return next(BoxError.toHttpError(error));

    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive',
        'X-Accel-Buffering': 'no',
        'Access-Control-Allow-Origin': '*'
    });
    res.write('retry: 3000\n');
    res.on('close', () => logStream.destroy());
    logStream.on('data', function (data) {
        const obj = JSON.parse(data);
        res.write(sse(obj.realtimeTimestamp, JSON.stringify(obj)));
    });
    logStream.on('end', res.end.bind(res));
    logStream.on('error', res.end.bind(res, null));
}

async function stopAppTask(req, res, next) {
    assert.strictEqual(typeof req.resources.task, 'object');

    const [error] = await safe(tasks.stopTask(req.resources.task.id));
    if (error) return next(BoxError.toHttpError(error));

    next(new HttpSuccess(204, {}));
}

export default {
    getApp,
    listByUser,
    getAppIcon,
    install,
    uninstall,
    archive,
    restore,
    importApp,
    exportApp,
    backup,
    update,
    getTask,
    getLogs,
    getLogStream,
    listEventlog,
    listBackups,
    listBackupSites,
    repair,

    setAccessRestriction,
    setOperators,
    setCrontab,
    setLabel,
    setTags,
    setNotes,
    setIcon,
    setTurn,
    setRedis,
    setMemoryLimit,
    setCpuQuota,
    setAutomaticBackup,
    setAutomaticUpdate,
    setReverseProxyConfig,
    setCertificate,
    setDebugMode,
    setEnvironment,
    setMailbox,
    setInbox,
    setLocation,
    setStorage,
    setMounts,
    setDevices,
    setUpstreamUri,

    setChecklistItem,

    stop,
    start,
    restart,

    createExec,
    startExec,
    startExecWebSocket,
    getExec,

    checkUpdate,

    clone,

    downloadFile,
    uploadFile,

    updateBackup,
    downloadBackup,

    getMetrics,
    getMetricStream,

    loadTask,
    listAppTasks,
    getAppTask,
    getAppTaskLogs,
    getAppTaskLogStream,
    stopAppTask,

    load
};
