diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1bfe9bc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.vscode/ +apos-build/ +badges/ +data/ +node-modules/ +public/uploads/ +.dockerignore +.env +.eslintignore +.gitignore +deploy-test-count +docker-compose.yaml +dockerfile +force-deploy +local.example.js \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6eea4e9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# Use an official Node runtime as a parent image (Alpine for smaller footprint) +FROM node:lts-alpine3.15 + +WORKDIR /srv/www/apostrophe + +RUN chown -R node: /srv/www/apostrophe +USER node + +COPY --chown=node package*.json /srv/www/apostrophe/ + +ENV NODE_ENV=production +RUN npm ci + +COPY --chown=node . /srv/www/apostrophe/ + +RUN ./scripts/build-assets.sh + +EXPOSE 3000 + +ENV APOS_MONGODB_URI="" +ENV ACTIVEMQ_HOST="" +ENV ACTIVEMQ_PORT="" +ENV ACTIVEMQ_USERNAME="" +ENV ACTIVEMQ_PASSWORD="" + +# Command to run the app +CMD [ "node", "app.js" ] diff --git a/app.js b/app.js new file mode 100644 index 0000000..56f1fc0 --- /dev/null +++ b/app.js @@ -0,0 +1,14 @@ +require('apostrophe')({ + shortName: 'NebulOuS', + modules: { + "application": {}, + "mathparser": {}, + "kubevela": {}, + "swagger": {}, + "userapi": {}, + "resources": {}, + "platforms": {}, + "policies": {} + } +}); + diff --git a/lib/area.js b/lib/area.js new file mode 100644 index 0000000..00e7e97 --- /dev/null +++ b/lib/area.js @@ -0,0 +1,43 @@ +module.exports = { + '@apostrophecms/video': {}, + '@apostrophecms/html': {}, + '@apostrophecms/rich-text': { + toolbar: [ + 'styles', + '|', + 'bold', + 'italic', + 'strike', + 'link', + '|', + 'bulletList', + 'orderedList', + '|', + 'blockquote', + 'codeBlock', + '|', + 'horizontalRule', + '|', + 'undo', + 'redo' + ], + styles: [ + { + tag: 'p', + label: 'Paragraph (P)' + }, + { + tag: 'h3', + label: 'Heading 3 (H3)' + }, + { + tag: 'h4', + label: 'Heading 4 (H4)' + } + ], + insert: [ + 'table', + 'image' + ] + } +}; diff --git a/lib/exn.js b/lib/exn.js new file mode 100644 index 0000000..a86c1ca --- /dev/null +++ b/lib/exn.js @@ -0,0 +1,261 @@ + + + +const connection_options={ + 'port': process.env.ACTIVEMQ_PORT, + 'host': process.env.ACTIVEMQ_HOST, + 'username': process.env.ACTIVEMQ_USERNAME, + 'password': process.env.ACTIVEMQ_PASSWORD, + 'reconnect': true +} + + +if(!connection_options.port || !connection_options.host) { + console.error("No connection option provided for EXN skipping asynchronous messaging") + return +} + + +const container= require('rhea'); +let connection; +let sender_sal_nodecandidate_get; +let sender_sal_cloud_get; +let sender_sal_cloud_post; +let sender_sal_cloud_delete; +let sender_sal_node_post; + +let sender_ui_application_new; +let sender_ui_application_updated; +let sender_ui_application_deploy; +let sender_ui_application_dsl_json; +let sender_ui_application_dsl_metric; + +let sender_ui_policies_rule_upsert; +let sender_ui_policies_model_upsert; + + +const correlations = {} + +container.on('message', (context)=>{ + + // console.log("Received ",context.message) + if(context.message.correlation_id in correlations){ + if(context.message.body.metaData['status'] >= 400){ + correlations[context.message.correlation_id]['reject'](context.message.body['message']) + }else{ + correlations[context.message.correlation_id]['resolve'](context.message.body) + } + } +}) + + +container.on('connection_open', function (context) { + + console.log("Connected ",context.container.id); + context.connection.open_receiver('topic://eu.nebulouscloud.exn.sal.cloud.get.reply') + context.connection.open_receiver('topic://eu.nebulouscloud.exn.sal.cloud.post.reply') + context.connection.open_receiver('topic://eu.nebulouscloud.exn.sal.cloud.delete.reply') + context.connection.open_receiver('topic://eu.nebulouscloud.exn.sal.nodecandidate.get.reply') + context.connection.open_receiver('topic://eu.nebulouscloud.exn.sal.node.post.reply') + + sender_sal_nodecandidate_get = context.connection.open_sender('topic://eu.nebulouscloud.exn.sal.nodecandidate.get'); + sender_sal_cloud_get = context.connection.open_sender('topic://eu.nebulouscloud.exn.sal.cloud.get'); + sender_sal_cloud_post = context.connection.open_sender('topic://eu.nebulouscloud.exn.sal.cloud.post'); + sender_sal_cloud_delete = context.connection.open_sender('topic://eu.nebulouscloud.exn.sal.cloud.delete'); + sender_sal_node_post = context.connection.open_sender('topic://eu.nebulouscloud.exn.sal.node.post'); + + sender_ui_application_new = context.connection.open_sender('topic://eu.nebulouscloud.ui.application.new'); + sender_ui_application_updated = context.connection.open_sender('topic://eu.nebulouscloud.ui.application.updated'); + sender_ui_application_deploy = context.connection.open_sender('topic://eu.nebulouscloud.ui.application.deploy'); + sender_ui_application_dsl_json = context.connection.open_sender('topic://eu.nebulouscloud.ui.dsl.generic'); + sender_ui_application_dsl_metric = context.connection.open_sender('topic://eu.nebulouscloud.ui.dsl.metric_model'); + + sender_ui_policies_rule_upsert = context.connection.open_sender('topic://eu.nebulouscloud.ui.policies.rule.upsert'); + sender_ui_policies_model_upsert = context.connection.open_sender('topic://eu.nebulouscloud.ui.policies.model.upsert'); + +}); + + + +connection = container.connect(); + +const {v4: uuidv4} = require("uuid"); + + +module.exports = { + sender_ui_application_new:(uuid) => { + return new Promise((resolve,reject) =>{ + const correlation_id = uuidv4() + correlations[correlation_id] = { + 'resolve':resolve, + 'reject':reject, + }; + const message = { + to: sender_ui_deploy_application_new.options.target.address, + correlation_id: correlation_id, + body:{ + uuid: uuid + } + } + console.log("Send ", message) + sender_ui_deploy_application_new.send(message) + + }) + }, + application_dsl:(uuid,json,yaml) => { + return new Promise((resolve,reject) =>{ + const correlation_id = uuidv4() + correlations[correlation_id] = { + 'resolve':resolve, + 'reject':reject, + }; + console.log("Sending ", sender_ui_application_dsl_json.options.target.address, uuid,json) + const message = { + to: sender_ui_application_dsl_json.options.target.address, + correlation_id: correlation_id, + body:json + } + sender_ui_application_dsl_json.send(message) + + console.log("Sending ", sender_ui_application_dsl_metric.options.target.address, uuid,json) + const metrci_message = { + to: sender_ui_application_dsl_metric.options.target.address, + correlation_id: correlation_id, + body:{ + 'yaml': yaml + } + } + sender_ui_application_dsl_metric.send(message) + + + + }) + }, + application_updated:(uuid) => { + return new Promise((resolve,reject) =>{ + const correlation_id = uuidv4() + correlations[correlation_id] = { + 'resolve':resolve, + 'reject':reject, + }; + const message = { + to: sender_ui_deploy_application_new.options.target.address, + correlation_id: correlation_id, + body:{ + uuid: uuid + } + } + console.log("Send ", message) + sender_ui_deploy_application_new.send(message) + + }) + }, + register_cloud:( uuid, user ,secret ) =>{ + return new Promise((resolve,reject)=>{ + + const correlation_id = uuidv4() + correlations[correlation_id] = { + 'resolve':resolve, + 'reject':reject, + }; + + const message = { + to: sender_sal_cloud_post.options.target.address, + correlation_id: correlation_id, + body:{ + metaData: { + userId: "admin" + }, + body: JSON.stringify([{ + "cloudId": uuid, + "cloudProviderName": "aws-ec2", + "cloudType": "PUBLIC", + "securityGroup": null, + "subnet": null, + "sshCredentials": { + "username": null, + "keyPairName": "mkl", + "privateKey": null + }, + "endpoint": null, + "scope": { + "prefix": null, + "value": null + }, + "identityVersion": null, + "defaultNetwork": null, + "credentials": { + "user": user, + "secret": secret, + "domain": null + }, + "blacklist": null + }]) + } + } + console.log("Send ", message) + sender_sal_cloud_post.send(message) + }) + }, + deploy_application: (uuid) => { + + + }, + get_cloud_candidates: () => { + return new Promise((resolve,reject)=> { + + const correlation_id = uuidv4() + correlations[correlation_id] = { + 'resolve': resolve, + 'reject': reject, + }; + + const message = { + to: sender_sal_nodecandidate_get.options.target.address, + correlation_id: correlation_id, + body: {} + } + sender_sal_nodecandidate_get.send(message) + }) + + }, + publish_policies:(policies) =>{ + return new Promise((resolve,reject)=> { + + const body = JSON.parse(policies) + body.forEach((b)=>{ + + const correlation_id = uuidv4() + const rule = { + to: sender_ui_policies_rule_upsert.options.target.address, + correlation_id: correlation_id, + body: [{ + "name": b['name'], + "policyItem": b['policyItem'] + }] + } + + const model = { + to: sender_ui_policies_model_upsert.options.target.address, + correlation_id: correlation_id, + body: [{ + "name": b['name'], + "enabled": true, + "modelText": b['model'] + }] + } + + sender_ui_policies_model_upsert.send(model) + sender_ui_policies_rule_upsert.send(rule) + + }) + + resolve() + + + }) + + } + + +} \ No newline at end of file diff --git a/lib/kubevela.js b/lib/kubevela.js new file mode 100644 index 0000000..ec3acae --- /dev/null +++ b/lib/kubevela.js @@ -0,0 +1,55 @@ +const slugify = require("slugify"); +const mathutils = require("./math"); +const _ = require("lodash"); + +module.exports = { + json: (doc) =>{ + let object = _.clone(doc) + object['variables'] = _.map(doc['variables'], (v)=>{ + + return { + 'key': slugify(v['name'].replaceAll('/','_'),'_'), + 'path': '/'+v['name'], + 'type': 'float', + 'meaning': v['name'].split('/').pop(), + 'value' :{ + "lower_bound": v['lowerValue'], + "higher_bound": v['higherValue'], + } + } + }) + object['sloViolations'] = JSON.parse(doc['sloViolations']) + object['metrics'] = _.map(doc['metrics'], (v)=>{ + + if(v['type'] === 'composite'){ + v['arguments'] = mathutils.extractVariableNames( + mathutils.extractFromEquation(v['formula'])) + } + + return v + }) + + object["utilityFunctions"] = _.map(doc['utilityFunctions'], (v)=>{ + return { + "name": v['functionName'], + "type": v['functionType'], + "expression":{ + "formula":v["functionExpression"], + "variables": _.map(v["functionExpressionVariables"], (k)=>{ + return { + "name":k['nameVariable'], + "value": slugify(k['valueVariable'].replaceAll('/','_'),'_') + } + }) + } + } + }) + + var protected_variables = ["_id","type",,"metaType","organization","_edit","_publish"] + _.each(protected_variables, (p)=>{ + delete object[p] + }) + + return object + } +} \ No newline at end of file diff --git a/lib/math.js b/lib/math.js new file mode 100644 index 0000000..5e7bb56 --- /dev/null +++ b/lib/math.js @@ -0,0 +1,33 @@ +const math = require('mathjs'); + + +module.exports = { + extractFromEquation: (equation)=>{ + equation = equation || ''; + const lowerCaseEquation = equation.toLowerCase(); + return math.parse(lowerCaseEquation); + }, + extractVariableNames: (mathNode) => { + let variableNames = new Set(); + + function traverse(node) { + if (node.type === 'SymbolNode') { + variableNames.add(node.name); + } + + for (const key in node.args) { + traverse(node.args[key]); + } + + if (node.content) { + traverse(node.content); + } + } + + traverse(mathNode); + + return Array.from(variableNames); + } + + +} \ No newline at end of file diff --git a/lib/metric_model.js b/lib/metric_model.js new file mode 100644 index 0000000..6470a8b --- /dev/null +++ b/lib/metric_model.js @@ -0,0 +1,149 @@ +const slugify = require("slugify"); +const _ = require("lodash"); +const yaml = require('yaml'); + +module.exports = { + yaml: (doc) => { + let object = _.clone(doc) + + const protectedVariables = ["_id", "type", "metaType", "organization", "_edit", "_publish", "variables", "utilityFunctions", "resources", "parameters",]; + protectedVariables.forEach(p => { + delete object[p]; + }); + + if (object.templates) { + object.templates = object.templates.map(v => { + + return { + + id: v.id, + type: v.type, + range: [v.minValue, v.maxValue], + unit: v.unit + } + }); + } + + object.metrics_comp = []; + object.metrics_global = []; + + if (object.metrics) { + object.metrics.forEach(v => { + let metricsDetail = {}; + + if (v.type === 'composite') { + const componentNames = v.components.map(component => component.componentName).join(', '); + + let windowDetail = {}; + + if (v.isWindowInput && v.input.type && v.input.interval && v.input.unit) { + windowDetail.type = v.input.type; + windowDetail.size = `${v.input.interval} ${v.input.unit}`; + } + if (v.isWindowOutput && v.output.type && v.output.interval && v.output.unit) { + windowDetail.output = `${v.output.type} ${v.output.interval} ${v.output.unit}`; + } + + metricsDetail = { + name: v.name, + type: v.type, + template: componentNames, + window: windowDetail + }; + + } else if (v.type === 'raw') { + + let windowDetailRaw = {}; + + if (v.isWindowInputRaw && v.inputRaw.type && v.inputRaw.interval && v.inputRaw.unit) { + windowDetailRaw.type = v.inputRaw.type; + windowDetailRaw.size = `${v.inputRaw.interval} ${v.inputRaw.unit}`; + } + if (v.isWindowOutputRaw && v.outputRaw.type && v.outputRaw.interval && v.outputRaw.unit) { + windowDetailRaw.output = `${v.outputRaw.type} ${v.outputRaw.interval} ${v.outputRaw.unit}`; + } + metricsDetail = { + name: v.name, + type: v.type, + sensor: { + type: v.sensor + }, + window: windowDetailRaw + }; + } + + const metric = { + metrics: metricsDetail + }; + + if (v.type === 'composite' && v.components.length < 2) { + object.metrics_global.push(metric); + } else if (v.type === 'composite' && v.components.length >= 2) { + object.metrics_comp.push(metric); + } else if (v.type === 'raw') { + object.metrics_global.push(metric); + } + }); + } + + + + + + if (object.sloViolations) { + const processSloViolations = (violations) => { + const buildConstraint = (v, parentCondition = '') => { + let constraint = ''; + if (!v.isComposite) { + constraint = `${v.metricName} ${v.operator} ${v.value}`; + } else { + const childConstraints = v.children.map(child => buildConstraint(child, v.condition)).join(` ${v.condition} `); + + if (v.not) { + constraint = `NOT (${childConstraints})`; + } else { + constraint = `(${childConstraints})`; + } + } + return constraint; + }; + + const combinedConstraint = buildConstraint(violations); + + const requirement = { + name: 'Combined SLO', + type: 'slo', + constraint: combinedConstraint + }; + + return [requirement]; + }; + + object.sloViolations = processSloViolations(JSON.parse(doc['sloViolations'])); + } + + const yamlDoc = { + apiVersion: "nebulous/v1", + kind: "MetricModel", + metadata: { + name: object.uuid, + labels: { + app: object.title, + } + }, + common: object.templates, + spec: { + components: object.metrics_comp + }, + scopes: [ + { + name: "app-wide-scope", + requirements: object.sloViolations, + components: object.metrics_global, + } + ] + }; + + return yamlDoc; + } +}; diff --git a/modules/@apostrophecms/admin-bar/index.js b/modules/@apostrophecms/admin-bar/index.js new file mode 100644 index 0000000..4554b77 --- /dev/null +++ b/modules/@apostrophecms/admin-bar/index.js @@ -0,0 +1,16 @@ +module.exports = { + options: { + groups: [ + { + name: 'media', + label: 'Media', + items: [ + '@apostrophecms/image', + '@apostrophecms/file', + '@apostrophecms/image-tag', + '@apostrophecms/file-tag' + ] + } + ] + } +}; \ No newline at end of file diff --git a/modules/@apostrophecms/express/index.js b/modules/@apostrophecms/express/index.js new file mode 100644 index 0000000..0ac5567 --- /dev/null +++ b/modules/@apostrophecms/express/index.js @@ -0,0 +1,8 @@ +module.exports = { + options: { + session: { + // If this still says `undefined`, set a real secret! + secret: 'abcxyz' + } + } +}; diff --git a/modules/@apostrophecms/home-page/index.js b/modules/@apostrophecms/home-page/index.js new file mode 100644 index 0000000..2c439f1 --- /dev/null +++ b/modules/@apostrophecms/home-page/index.js @@ -0,0 +1,6 @@ +module.exports = { + options: { + label: '' + }, + fields: {} +}; diff --git a/modules/@apostrophecms/home-page/views/page.html b/modules/@apostrophecms/home-page/views/page.html new file mode 100644 index 0000000..c9280dc --- /dev/null +++ b/modules/@apostrophecms/home-page/views/page.html @@ -0,0 +1,9 @@ +{# + This is an example home page template. It inherits and extends a layout template + that lives in the top-level views/ folder for convenience +#} + +{% extends "layout.html" %} + +{% block main %} +{% endblock %} diff --git a/modules/@apostrophecms/settings/index.js b/modules/@apostrophecms/settings/index.js new file mode 100644 index 0000000..b8e594b --- /dev/null +++ b/modules/@apostrophecms/settings/index.js @@ -0,0 +1,35 @@ +module.exports = { + options: { + subforms: { + displayname: { + fields: [ 'title' ], + reload: true + }, + changePassword: { + fields: [ 'password' ] + }, + fullname: { + label: 'Full Name', + fields: [ 'firstname', 'lastname' ], + preview: '{{ firstname }} {{lastname}}' + }, + organization: { + label: 'Organization', + type: 'string', + fields: [ 'organization' ] + }, + uuid: { + label: 'UUID', + type: 'string', + fields: [ 'uuid' ] + } + }, + + groups: { + account: { + label: 'Account', + subforms: [ 'displayname', 'fullname', 'changePassword', 'organization', 'uuid' ] + } + } + } +}; diff --git a/modules/@apostrophecms/user/index.js b/modules/@apostrophecms/user/index.js new file mode 100644 index 0000000..4246f9e --- /dev/null +++ b/modules/@apostrophecms/user/index.js @@ -0,0 +1,31 @@ +module.exports = { + fields: { + add: { + firstname: { + type: 'string', + label: 'First Name' + }, + lastname: { + type: 'string', + label: 'Last Name' + }, + organization: { + type: 'string', + label: 'Organization', + required: true, + group: 'basics' + }, + uuid: { + type: 'string', + label: 'UUID', + required: true + }, + }, + group: { + basics: { + label: 'Basics', + fields: ['firstname', 'lastname','organization', 'uuid'] + } + } + } +}; \ No newline at end of file diff --git a/modules/application/index.js b/modules/application/index.js new file mode 100644 index 0000000..0211aec --- /dev/null +++ b/modules/application/index.js @@ -0,0 +1,1204 @@ +const { v4: uuidv4 } = require('uuid'); +const Joi = require('joi'); +const yaml = require('yaml'); +const slugify = require('slugify'); +const mathutils = require('../../lib/math'); +const metric_model = require('../../lib/metric_model'); +const kubevela = require('../../lib/kubevela') +const exn = require('../../lib/exn') +const _=require('lodash') + +const container = require('rhea'); +let connection; +let application_new_sender; +let application_update_sender; +let application_dsl_generic; +let application_dsl_metric; +const projection = { + title: 1, + uuid: 1, + status: 1, + organization: 1, + content: 1, + variables: 1, + environmentVariables: 1, + resources: 1, + parameters: 1, + templates: 1, + metrics: 1, + sloViolations: 1, + utilityFunctions: 1 +}; + + +module.exports = { + extend: '@apostrophecms/piece-type', + options: { + label: 'Application', + }, + fields: { + add: { + uuid: { + type: 'string', + label: 'UUID' + }, + status: { + type: 'string', + label: 'Status', + def: 'draft' + }, + content: { + type: 'string', + textarea: true, + label: 'Content (YAML format)' + }, + variables: { + type: 'array', + label: 'Variables', + fields: { + add: { + name: { + type: 'string', + label: 'Name' + }, + fullPath: { + type: 'string', + label: 'Full Path' + }, + lowerValue: { + type: 'float', + label: 'Lower Value', + }, + higherValue: { + type: 'float', + label: 'Higher Value', + } + } + } + }, + environmentVariables: { + type: 'array', + label: 'Environmental Variables', + fields: { + add: { + name: { + type: 'string', + label: 'Name' + }, + value: { + type: 'string', + label: 'Value', + }, + secret: { + type: 'boolean', + label: 'Secret', + def: false + } + } + } + }, + resources: { + type: 'array', + label: 'Application Resources', + fields: { + add: { + uuid: { + type: 'string', + label: 'Resource UUID', + }, + title: { + type: 'string', + label: 'Resource Title' + }, + platform: { + type: 'string', + label: 'Platform Name', + }, + enabled: { + type: 'boolean', + label: 'Enabled', + def: false + } + } + } + }, + parameters: { + type: 'array', + label: 'Parameters', + fields: { + add: { + name: { + type: 'string', + label: 'Name' + }, + template: { + type: 'string', + label: 'Template' + } + } + } + }, + templates: { + type: 'array', + label: 'Templates', + fields: { + add: { + id: { + type: 'string', + label: 'ID', + }, + type: { + type: 'select', + label: 'Type', + choices: [ + { label: 'Integer', value: 'int' }, + { label: 'Double', value: 'double' } + ] + }, + minValue: { + type: 'integer', + label: 'Minimum Value', + }, + maxValue: { + type: 'integer', + label: 'Maximum Value', + }, + unit: { + type: 'string', + label: 'Unit', + } + } + } + }, + metrics: { + type: 'array', + label: 'Metrics', + fields: { + add: { + + type: { + type: 'select', + label: 'Type', + choices: [ + {label: 'Composite', value: 'composite'}, + {label: 'Raw', value: 'raw'} + ] + }, + level: { + type: 'select', + label: 'Level', + choices: [ + { label: 'Global', value: 'Global' }, + { label: 'Components', value: 'Components' } + ], + def: 'global' + }, + components: { + type: 'array', + label: 'Components', + if: { + level: 'Components' + }, + fields: { + add: { + componentName: { + type: 'string', + label: 'Component Name' + } + } + } + }, + name: { + type: 'string', + label: 'Name', + if: { + type: ['composite', 'raw'] + } + }, + formula: { + type: 'string', + label: 'Formula', + textarea: true, + if: { + type: 'composite' + } + }, + isWindowInput: { + type: 'boolean', + label: 'Window Input', + if: { + type: 'composite' + } + }, + input: { + type: 'object', + label: 'Input', + fields: { + add: { + type: { + type: 'select', + label: 'Type Input', + choices: [ + {label: 'All', value: 'all'}, + {label: 'Sliding', value: 'sliding'} + ], + }, + interval: { + type: 'integer', + label: 'Interval', + }, + unit: { + type: 'select', + label: 'Unit', + choices: [ + {label: 'Ms', value: 'ms'}, + {label: 'Sec', value: 'sec'}, + {label: 'Min', value: 'min'}, + {label: 'Hour', value: 'hour'}, + {label: 'Day', value: 'day'} + ], + }, + }, + }, + if: { + isWindowInput: true + } + }, + isWindowOutput: { + type: 'boolean', + label: 'Window Output', + if: { + type: 'composite' + } + }, + output: { + type: 'object', + label: 'Output', + fields: { + add: { + type: { + type: 'select', + choices: [ + {label: 'All', value: 'all'}, + {label: 'Sliding', value: 'sliding'} + ], + }, + interval: { + type: 'integer', + label: 'Interval' + }, + unit: { + type: 'select', + label: 'Unit', + choices: [ + {label: 'Ms', value: 'ms'}, + {label: 'Sec', value: 'sec'}, + {label: 'Min', value: 'min'}, + {label: 'Hour', value: 'hour'}, + {label: 'Day', value: 'day'} + ], + } + } + }, + if: { + isWindowOutput: true + } + }, + sensor: { + type: 'string', + label: 'Sensor', + if: { + type: 'raw' + } + }, + config: { + type: 'array', + label: 'Config', + if: { + type: 'raw' + }, + fields: { + add: { + name: { + type: 'string', + label: 'Name' + }, + value: { + type: 'string', + label: 'Value' + } + } + } + }, + isWindowInputRaw: { + type: 'boolean', + label: 'Window Input', + if: { + type: 'raw' + } + }, + inputRaw: { + type: 'object', + label: 'Input', + fields: { + add: { + type: { + type: 'select', + label: 'Type Input', + choices: [ + {label: 'All', value: 'all'}, + {label: 'Sliding', value: 'sliding'} + ], + }, + interval: { + type: 'integer', + label: 'Interval', + }, + unit: { + type: 'select', + label: 'Unit', + choices: [ + {label: 'Ms', value: 'ms'}, + {label: 'Sec', value: 'sec'}, + {label: 'Min', value: 'min'}, + {label: 'Hour', value: 'hour'}, + {label: 'Day', value: 'day'} + ], + }, + }, + }, + if: { + isWindowInputRaw: true + } + }, + isWindowOutputRaw: { + type: 'boolean', + label: 'Window Output', + if: { + type: 'raw' + } + }, + outputRaw: { + type: 'object', + label: 'Output', + fields: { + add: { + type: { + type: 'select', + choices: [ + {label: 'All', value: 'all'}, + {label: 'Sliding', value: 'sliding'} + ], + }, + interval: { + type: 'integer', + label: 'Interval' + }, + unit: { + type: 'select', + label: 'Unit', + choices: [ + {label: 'Ms', value: 'ms'}, + {label: 'Sec', value: 'sec'}, + {label: 'Min', value: 'min'}, + {label: 'Hour', value: 'hour'}, + {label: 'Day', value: 'day'} + ], + } + } + }, + if: { + isWindowOutputRaw: true + } + }, + } + } + }, + sloViolations: { + type: 'string', + label: 'SLO', + textarea: true, + }, + utilityFunctions: { + type: 'array', + label: 'Utility Functions', + fields: { + add: { + functionName: { + type: 'string', + label: 'Function Name' + }, + functionType: { + type: 'select', + label: 'Function Type', + choices: [ + {label: 'Maximize', value: 'maximize'}, + {label: 'Constant', value: 'constant'} + ] + }, + functionExpression: { + type: 'string', + label: 'Function Expression', + textarea: true + }, + functionExpressionVariables: { + type: 'array', + label: 'Expression Variables', + fields: { + add: { + nameVariable: { + type: 'string', + label: 'Name Variable Value' + }, + valueVariable: { + type: 'string', + label: 'Expression Variable Value' + } + } + } + }, + } + } + } + }, + group: { + basics: { + label: 'Details', + fields: ['title', 'uuid','status', 'content', 'variables','environmentVariables'] + }, + resources: { + label: 'Resources', + fields: ['resources'] + }, + templates: { + label: 'Templates', + fields: ['templates'] + }, + parameters: { + label: 'Parameters', + fields: ['parameters'] + }, + metricsGroup: { + label: 'Metrics', + fields: ['metrics', 'sloViolations'] + }, + expressionEditor: { + label: 'Expression Editor', + fields: ['utilityFunctions'] + } + } + }, + + handlers(self) { + return { + // 'apostrophe:ready': { + // async setUpActiveMq() { + // console.log("Set up rhea", + // self.options.amqp_host, self.options.amqp_port); + // + // container.on('connection_open', function (context) { + // application_new_sender = context.connection.open_sender('topic://eu.nebulouscloud.ui.application.new'); + // application_update_sender = context.connection.open_sender('topic://eu.nebulouscloud.ui.application.update'); + // application_dsl_generic = context.connection.open_sender('topic://eu.nebulouscloud.ui.application.dsl.generic'); + // application_dsl_metric = context.connection.open_sender('topic://eu.nebulouscloud.ui.application.dsl.metric_model'); + // }); + // + // connection = container.connect({ + // 'host': self.options.amqp_host, + // 'port': self.options.amqp_port, + // 'reconnect':true, + // 'username':'admin', + // 'password':'admin' + // }); + // + // } + // }, + beforeInsert: { + async generateUuid(req, doc, options) { + if (!doc.uuid) { + doc.uuid = uuidv4(); + } + if (req.user && req.user.organization) { + doc.organization = req.user.organization; + } + } + }, + // afterInsert:{ + // async postMessages(req,doc,option){ + // console.log("Application created " + doc.uuid) + // + // //produce application.json + // + // + // //product metric model + // + // //post to activemq + // application_new_sender.send({ + // "body":{"uuid":doc.uuid}, + // }); + // application_dsl_generic.send({body:{}}); + // + // } + // }, + // afterSave: { + // async processAppAfterSave(req, doc, options) { + // try { + // console.log("UUID:", doc.uuid); + // const applicationData = await self.getApplicationData(doc.uuid); + // return applicationData; + // } catch (error) { + // console.error('Error', error); + // } + // } + // } + afterDeploy:{ + async deployApplication(req,doc,options){ + console.log("After deployment",doc.uuid) + if(connection){ + application_update_sender.send({ + "body":{"uuid":doc.uuid}, + "message_annotations":{ + "subject":doc.uuid + } + }); + application_dsl_generic.send({body:{}, "message_annotations":{ + "subject":doc.uuid + }}); + } + } + }, + afterUpdate:{ + async postMessages(req,doc,option){ + console.log("After update", doc.uuid); + + //produce application.json + + + //product metric model + + //post to activemq + + // eu.nebulouscloud.ui.application.new + + // eu.nebulouscloud.ui.application.updated + if(connection){ + application_update_sender.send({ + "body":{"uuid":doc.uuid}, + "message_annotations":{ + "subject":doc.uuid + } + }); + application_dsl_generic.send({body:{}, "message_annotations":{ + "subject":doc.uuid + }}); + } + } + } + }; + }, + methods(self) { + const contentSchema = Joi.string().custom((value, helpers) => { + try { + yaml.parse(value); + return value; + } catch (err) { + return helpers.error('string.yaml'); + } + }).messages({ + 'string.yaml': "Content must be in valid YAML format.", + }); + + const variableSchema = Joi.object({ + name: Joi.string().trim().required().messages({ + 'string.empty': "Please enter a name.", + 'any.required': "Name is a required field." + }), + lowerValue: Joi.number().required().messages({ + 'number.base': "Lower value must be a valid number.", + 'any.required': "Lower value is a required field." + }), + higherValue: Joi.number().min(Joi.ref('lowerValue')).required().messages({ + 'number.base': "Higher value must be a valid number.", + 'number.min': "Higher value must be greater than or equal to the lower value.", + 'any.required': "Higher value is a required field." + }) + }).unknown(); + + const resourcesSchema = Joi.object({ + title: Joi.string().trim().required().messages({ + 'string.empty': 'Resource Title cannot be empty.', + 'any.required': 'Resource Title is a required field.' + }), + uuid: Joi.string().trim().required().messages({ + 'string.empty': 'Resource UUID cannot be empty.', + 'any.required': 'Resource UUID is a required field.' + }), + platform: Joi.string().required().messages({ + 'any.only': 'Resource Platform must be one of AWS, AZURE, GCP, BYON.', + 'string.empty': 'Resource Platform cannot be empty.', + 'any.required': 'Resource Platform is a required field.' + }), + enabled: Joi.boolean().messages({ + 'boolean.base': 'Enabled must be a boolean value.' + }) + }).unknown(); + + const parameterSchema = Joi.object({ + name: Joi.string().trim().required().messages({ + 'string.empty': "Name cannot be empty.", + 'any.required': "Name is a required field." + }), + template: Joi.string().trim().required().messages({ + 'string.empty': "Template cannot be empty.", + 'any.required': "Template is a required field." + }) + }).unknown(); + + const templateSchema = Joi.object({ + id: Joi.string().trim().required().messages({ + 'string.empty': "ID cannot be empty.", + 'any.required': "ID is a required field." + }), + type: Joi.string().valid('int', 'double').required().messages({ + 'string.base': 'Type must be a string.', + 'any.required': 'Type is required.', + 'any.only': 'Type must be either "Integer" or "Double".' + }), + minValue: Joi.number().integer().messages({ + 'number.base': 'Minimum Value must be a number.', + 'number.integer': 'Minimum Value must be an integer.' + }), + maxValue: Joi.number().integer().messages({ + 'number.base': 'Maximum Value must be a number.', + 'number.integer': 'Maximum Value must be an integer.' + }), + unit: Joi.string().trim().messages({ + 'string.base': 'Unit must be a string.' + }) + }).unknown(); + + // const metricSchema = Joi.object({ + // type: Joi.string().valid('composite', 'raw').required().messages({ + // 'any.required': 'Metric type is required.', + // 'string.valid': 'Metric type must be either "composite" or "raw".' + // }), + // level: Joi.string().required().messages({ + // 'string.base': 'Level must be a string.', + // 'any.required': 'Level is required.', + // 'any.only': 'Level must be either "Global" or "Components".' + // }), + // components: Joi.when('level', { + // is: 'components', + // then: Joi.array().items(Joi.object({ + // componentName: Joi.string().trim().required().messages({ + // 'string.base': 'Component Name must be a string.', + // 'any.required': 'Component Name is required in components.' + // }) + // }).unknown()).required() + // }).required(), + // name: Joi.when('type', { + // is: 'composite,raw', + // then: Joi.string().trim().required().messages({ + // 'any.required': 'Name is required for composite metrics.', + // 'string.empty': 'Name cannot be empty.' + // }) + // }), + // formula: Joi.when('type', { + // is: 'composite', + // then: Joi.string().trim().required().messages({ + // 'any.required': 'Formula is required for composite metrics.', + // 'string.empty': 'Formula cannot be empty.' + // }) + // }), + // isWindowInput: Joi.when('type', { + // is: 'composite', + // then: Joi.boolean().required() + // }), + // input: Joi.when('isWindowInput', { + // is: true, + // then: Joi.object({ + // type: Joi.string().valid('all', 'sliding').required(), + // interval: Joi.number().integer().required(), + // unit: Joi.string().valid('ms', 'sec', 'min', 'hour', 'day').required() + // }).required() + // }), + // isWindowOutput: Joi.when('type', { + // is: 'composite', + // then: Joi.boolean().required() + // }), + // output: Joi.when('isWindowOutput', { + // is: true, + // then: Joi.object({ + // type: Joi.string().valid('all', 'sliding').required(), + // interval: Joi.number().integer().required(), + // unit: Joi.string().valid('ms', 'sec', 'min', 'hour', 'day').required() + // }).required() + // }), + // sensor: Joi.when('type', { + // is: 'raw', + // then: Joi.string().trim().required() + // }), + // config: Joi.when('type', { + // is: 'raw', + // then: Joi.array().items( + // Joi.object({ + // name: Joi.string().trim().required().messages({ + // 'string.base': 'Name must be a string.', + // 'any.required': 'Name is required in config for raw type.' + // }), + // value: Joi.string().trim().required().messages({ + // 'string.base': 'Value must be a string.', + // 'any.required': 'Value is required in config for raw type.' + // }), + // }).unknown(), + // ).required() + // }).messages({ + // 'any.required': 'Config is required for raw type.' + // }), + // isWindowInputRaw: Joi.when('type', { + // is: 'raw', + // then: Joi.boolean().required() + // }), + // inputRaw: Joi.when('isWindowInputRaw', { + // is: true, + // then: Joi.object({ + // type: Joi.string().valid('all', 'sliding').required(), + // interval: Joi.number().integer().required(), + // unit: Joi.string().valid('ms', 'sec', 'min', 'hour', 'day').required() + // }).required() + // }), + // isWindowOutputRaw: Joi.when('type', { + // is: 'raw', + // then: Joi.boolean().required() + // }), + // outputRaw: Joi.when('isWindowOutputRaw', { + // is: true, + // then: Joi.object({ + // type: Joi.string().valid('all', 'sliding').required(), + // interval: Joi.number().integer().required(), + // unit: Joi.string().valid('ms', 'sec', 'min', 'hour', 'day').required() + // }).required() + // }) + // }).unknown(); + + const utilityFunctionSchema = Joi.object({ + functionName: Joi.string().trim().required().messages({ + 'string.base': 'Function Name must be a string.', + 'any.required': 'Function Name is required.' + }), + functionType: Joi.string().valid('maximize', 'constant').insensitive().required().messages({ + 'string.base': 'Function Type must be a string.', + 'any.required': 'Function Type is required.', + 'any.only': 'Function Type must be either "Maximize" or "Constant".' + }), + functionExpression: Joi.string().trim().required().messages({ + 'string.base': 'Function Expression must be a string.', + 'any.required': 'Function Expression is required.' + }), + functionExpressionVariables: Joi.array().items( + Joi.object({ + nameVariable: Joi.string().trim().required().messages({ + 'string.base': 'Name Variable Value must be a string.', + 'any.required': 'Name Variable Value is required.' + }), + valueVariable: Joi.string().trim().required().messages({ + 'string.base': 'Expression Variable Value must be a string.', + 'any.required': 'Expression Variable Value is required.' + }) + }).unknown().required() + ).messages({ + 'array.base': 'Expression Variables must be an array.' + }) + }).unknown(); + + return { + isValidStateTransition(currentState, newState) { + const validTransitions = { + 'draft': ['valid'], + 'valid': ['deploying'], + 'deploying': ['running'], + 'running': ['draft'] + }; + + if (validTransitions[currentState].indexOf(newState) === -1) { + return false; + } + + return true; + }, + validateDocument(doc) { + let errorResponses = []; + + const validateArray = (dataArray, schema, arrayName) => { + if (Array.isArray(dataArray)) { + dataArray.forEach((item, index) => { + const { error } = schema.validate(item); + if (error) { + error.details.forEach(detail => { + let message = detail.message.replace(/\"/g, ""); + errorResponses.push({ + path: `${arrayName}[${index}].${detail.path.join('.')}`, + index: index.toString(), + key: detail.path[detail.path.length - 1], + message: message + }); + }); + } + }); + } + }; + const validateField = (data, schema, fieldName) => { + const { error } = schema.validate(data); + if (error) { + error.details.forEach(detail => { + let message = detail.message.replace(/\"/g, ""); + errorResponses.push({ + path: `${fieldName}.${detail.path.join('.')}`, + message: message + }); + }); + } + }; + + validateField(doc.content, contentSchema, 'content'); + validateArray(doc.variables, variableSchema, 'variables'); + validateArray(doc.resources, resourcesSchema, 'resources'); + validateArray(doc.parameters, parameterSchema, 'parameters'); + validateArray(doc.templates, templateSchema, 'templates'); + //validateArray(doc.metrics, metricSchema, 'metrics'); + validateArray(doc.utilityFunctions, utilityFunctionSchema, 'utilityFunctions'); + + if (errorResponses.length > 0) { + throw self.apos.error('required', 'Validation failed', {error: errorResponses}); + } + }, + async getApplicationData(uuid) { + try { + + const application = await self.apos.doc.db.findOne({ uuid: uuid }); + if (!application) { + throw self.apos.error('notfound', 'Application not found', { uuid }); + } + + + const data = { + application: { + name: application.title, + uuid: application.uuid + }, + kubvela: { + original: application.content, + variables: application.variables.map(variable => ({ + key: variable.name, + value: variable.isConstant ? { + lower: variable.value, + upper: false + } : { + lower: variable.lowerValue, + upper: variable.higherValue + }, + meaining: '...', + type: 'float', + is_constant: variable.isConstant + })) + }, + cloud_providers: application.resources.map(resource => ({ + type: resource.platform, + sal_key: resource.uuid + })), + metrics: application.metrics.map(metric => ({ + type: metric.type, + key: metric.nameResult, + name: metric.name, + formula: metric.type === 'composite' ? metric.formula : undefined, + window: metric.type === 'composite' && metric.isWindow ? { + input: metric.input ? { + type: metric.input.type, + interval: metric.input.interval, + unit: metric.input.unit + } : {}, + output: metric.output ? { + type: metric.output.type, + interval: metric.output.interval, + unit: metric.output.unit + } : {} + } : undefined, + sensor: metric.type === 'raw' ? metric.sensor : undefined, + config: metric.type === 'raw' ? metric.config.map(c => ({ + name: c.name, + value: c.value + })) : undefined + })), + slo: JSON.parse(application.sloViolations), + utility_functions: application.utilityFunctions.map(func => ({ + key: func.functionName, + name: func.functionName, + type: func.functionType, + formula: func.functionExpression, + mapping: func.functionExpressionVariables.reduce((map, variable) => { + map[variable.nameVariable] = variable.valueVariable; + return map; + }, {}) + })) + }; + + return data; + } catch (error) { + throw self.apos.error('notfound', 'Application not found', {uuid}); + } + } + }; + }, + apiRoutes(self) { + return { + post: { + async validate (req) { + if (!self.apos.permission.can(req, 'edit')) { + throw self.apos.error('forbidden', 'Insufficient permissions'); + } + + const doc = req.body; + let errorResponses = self.validateDocument(doc) || []; + if (errorResponses.length > 0) { + throw self.apos.error('required', 'Validation failed', { error: errorResponses }); + } + }, + async ':uuid/uuid/deploy' (req) { + + const uuid = req.params.uuid; + + // let errorResponses = self.validateDocument(updateData, true) || []; + // if (errorResponses.length > 0) { + // throw self.apos.error('required', 'Validation failed', { error: errorResponses }); + // } + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + const existingApp = await self.apos.doc.db.findOne({ uuid: uuid , organization:adminOrganization }); + if (!existingApp) { + throw self.apos.error('notfound', 'Application not found'); + } + + try { + + const updatedApp = await self.find(req,{ uuid: uuid , organization:adminOrganization }).project(projection).toArray(); + const result = await exn.application_dsl(uuid, + kubevela.json(updatedApp.pop()), + "" + ) + //TODO refactor to use apostrophe CMS ORM + await self.apos.doc.db.updateOne( + { uuid: uuid }, + { $set: {'status':'deploying'} } + ); + if(updatedApp.length > 0 ){ + await self.emit('afterDeploy', req, updatedApp[0]); + } + return { status: 'deployed', message: 'Application deployed successfully', updatedResource: updatedApp }; + + } catch (error) { + throw self.apos.error(error.name, error.message); + } + + } + + }, + get: { + async all(req) { + if (!self.apos.permission.can(req, 'view')) { + throw self.apos.error('forbidden', 'Insufficient permissions'); + } + + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + try { + const filters = {}; + filters.organization = adminOrganization; + + + const docs = await self.find(req, filters).project(projection).toArray(); + return docs; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + }, + async ':uuid/uuid'(req) { + const uuid = req.params.uuid; + + if (!self.apos.permission.can(req, 'view')) { + throw self.apos.error('forbidden', 'Insufficient permissions'); + } + + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + try { + const doc = await self.find(req, { uuid: uuid , organization:adminOrganization}).project(projection).toObject(); + if (!doc) { + throw self.apos.error('notfound', 'Application not found'); + } + + if (doc.organization !== adminOrganization) { + throw self.apos.error('forbidden', 'Access denied'); + } + + return doc; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + }, + async ':uuid/json'(req) { + const uuid = req.params.uuid; + + if (!self.apos.permission.can(req, 'view')) { + throw self.apos.error('forbidden', 'Insufficient permissions'); + } + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + try { + const doc = await self.find(req, { uuid: uuid , organization:adminOrganization}).project(projection).toObject(); + if (!doc) { + throw self.apos.error('notfound', 'Application not found'); + } + + if (doc.organization !== adminOrganization) { + throw self.apos.error('forbidden', 'Access denied'); + } + + let json_output = kubevela.json(doc) + req.res.type('application/json'); + req.res.setHeader('Content-Disposition', `attachment; filename="${uuid}.json"`); + return json_output; + + } catch (error) { + throw self.apos.error(error.name, error.message); + } + }, + async ':uuid/yaml'(req) { + const uuid = req.params.uuid; + + if (!self.apos.permission.can(req, 'view')) { + throw self.apos.error('forbidden', 'Insufficient permissions'); + } + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + try { + const doc = await self.find(req, { uuid: uuid, organization: adminOrganization }).project(projection).toObject(); + if (!doc) { + throw self.apos.error('notfound', 'Application not found'); + } + + if (doc.organization !== adminOrganization) { + throw self.apos.error('forbidden', 'Access denied'); + } + + req.res.type('application/yaml'); + req.res.setHeader('Content-Disposition', `attachment; filename="${uuid}.yaml"`); + + //const yamlContent = yaml.stringify(doc); + const yamlContent = metric_model.yaml(doc); + const test = yaml.stringify(yamlContent); + + + return test; + + } catch (error) { + throw self.apos.error(error.name, error.message); + } + } + }, + delete: { + async ':uuid/uuid'(req) { + const uuid = req.params.uuid; + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + if (!uuid) { + throw self.apos.error('invalid', 'UUID is required'); + } + + const doc = await self.find(req, { uuid: uuid , organization:adminOrganization }).toObject(); + if (!doc) { + throw self.apos.error('notfound', 'Application not found'); + } + + if (!self.apos.permission.can(req, 'delete')) { + throw self.apos.error('forbidden', 'You do not have permission to perform this action'); + } + + //Validation of the state of Application + // if (doc.status === 'deploying' || doc.status === 'running') { + // throw self.apos.error('forbidden', 'Application cannot be deleted while deploying or running'); + // } + + try { + const docs = await self.apos.db.collection('aposDocs').find({ uuid: uuid, organization:adminOrganization }).toArray(); + + if (!docs || docs.length === 0) { + throw self.apos.error('notfound', 'Document not found'); + } + + if (docs[0].organization !== adminOrganization) { + throw self.apos.error('forbidden', 'Access denied'); + } + + for (const doc of docs) { + await self.apos.db.collection('aposDocs').deleteOne({ _id: doc._id }); + } + + return { status: 'success', message: 'Application deleted successfully' }; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + } + }, + patch: { + async ':uuid/uuid'(req) { + const uuid = req.params.uuid; + const updateData = req.body; + + // let errorResponses = self.validateDocument(updateData, true) || []; + // if (errorResponses.length > 0) { + // throw self.apos.error('required', 'Validation failed', { error: errorResponses }); + // } + + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + const existingApp = await self.apos.doc.db.findOne({ uuid: uuid , organization:adminOrganization }); + if (!existingApp) { + throw self.apos.error('notfound', 'Application not found'); + } + const currentState = existingApp.status; + const newState = updateData.status; + + //Validation of the state of Application + // if (!self.isValidStateTransition(currentState, newState)) { + // throw self.apos.error('invalid', 'Invalid state transition'); + // } + + + try { + await self.apos.doc.db.updateOne( + { uuid: uuid }, + { $set: updateData } + ); + + //TODO refactor to use apostrophe CMS ORM + const updatedApp = await self.find(req,{ uuid: uuid , organization:adminOrganization }).project(projection).toArray(); + if(updatedApp.length > 0 ){ + await self.emit('afterUpdate', req, updatedApp[0]); + } + return { status: 'success', message: 'Application partially updated successfully', updatedResource: updatedApp }; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + } + }, + }; + } +}; + diff --git a/modules/kubevela/index.js b/modules/kubevela/index.js new file mode 100644 index 0000000..48a75e0 --- /dev/null +++ b/modules/kubevela/index.js @@ -0,0 +1,127 @@ +const yaml = require('yaml'); +const flat=require( 'flat'); +const _= require('lodash') +module.exports = { + apiRoutes(self) { + return { + post: { + async keys(req) { + try { + const { content } = req.body; + if (!content) { + return []; + } + + const query = req.query.q; + const yamlData = yaml.parse(content); + + if (!yamlData) { + throw self.apos.error('invalid', 'Invalid YAML data.') + } + + const flattenKeys = flat.flatten(yamlData,{'delimiter':'/'}) + if (!flattenKeys || flattenKeys.length === 0) { + return []; + } + return _.map(flattenKeys, (k,v)=>{ + return { + 'value': v, + 'label': formatLabel(v,'/') + } + }) + } catch (error) { + console.error('Error processing YAML:', error.message); + throw error; + } + }, + async 'components'(req) { + try { + const { content } = req.body; + if (!content) { + return []; + } + + const yamlData = yaml.parse(content); + + if (!yamlData || !yamlData.spec || !yamlData.spec.components) { + return [] + } + + const components = yamlData.spec.components; + const componentNames = components + .filter(component => component.name) + .map(component => ({ + value: component.name, + label: component.name + })); + + return componentNames; + } catch (error) { + console.error('Error processing YAML:', error.message); + throw error; + } + } + + }, + }; + }, +}; + +function formatLabel(key,delimiter='.') { + const specComponentsPropertiesPrefix = 'spec/components/properties'; + if (key.startsWith(specComponentsPropertiesPrefix)) { + key = key.substring(specComponentsPropertiesPrefix.length + 1); // +1 for the dot after the prefix + } + const parts = key.split(delimiter); + let ret = key + if (parts.length > 4) { + + const firstThree = parts.slice(0, 3).join(delimiter); + const lastOne = parts[parts.length - 1]; + const dots = '.'.repeat(parts.length - 4); // Repeat '.' for the number of parts - 4 + ret = `${firstThree}[${dots}]${lastOne}`; + } + + return ret; +} + + + +function findKeys(obj, query, currentPath = []) { + const keys = []; + for (const [key, value] of Object.entries(obj)) { + const newPath = [...currentPath, key]; + const currentKey = newPath.join('.'); + + // if (currentKey.startsWith(query)) { + // keys.push(currentKey); + // } + + if (currentKey.startsWith(query)) { + const adjustedKey = query.startsWith('spec.') ? currentKey.substring(5) : currentKey; + keys.push(adjustedKey); + } + + if (typeof value === 'object' && value !== null) { + if (Array.isArray(value)) { + value.forEach(item => { + if (typeof item === 'object') { + keys.push(...findKeys(item, query, newPath)); + } + }); + } else { + keys.push(...findKeys(value, query, newPath)); + } + } + } + return filterRedundantKeys(keys); +} + + +function filterRedundantKeys(keys) { + return keys.filter((key, index, self) => { + return !self.some((otherKey) => { + return otherKey.startsWith(key + '.') && otherKey !== key; + }); + }); +} \ No newline at end of file diff --git a/modules/mathparser/index.js b/modules/mathparser/index.js new file mode 100644 index 0000000..88c9de9 --- /dev/null +++ b/modules/mathparser/index.js @@ -0,0 +1,22 @@ +var mathutils = require('../../lib/math' +) +module.exports = { + apiRoutes(self) { + return { + post: { + async expression(req) { + try { + let parsedEquation = mathutils.extractFromEquation(req.body.equation) + const variableNames = mathutils.extractVariableNames(parsedEquation); + const uppercaseVariableNames = variableNames.map(name => name.toUpperCase()); + return { + variables: uppercaseVariableNames, + }; + } catch (error) { + throw error; + } + }, + }, + }; + }, +}; diff --git a/modules/platforms/index.js b/modules/platforms/index.js new file mode 100644 index 0000000..b62b536 --- /dev/null +++ b/modules/platforms/index.js @@ -0,0 +1,52 @@ +const {v4: uuidv4} = require("uuid"); +module.exports = { + extend: '@apostrophecms/piece-type', + options: { + label: 'Platform', + }, + + fields: { + add: { + uuid: { + type: 'string', + label: 'UUID', + required: false + } + }, + group: { + basics: { + label: 'Basics', + fields: ['uuid'] + } + } + }, + handlers(self) { + return { + beforeSave: { + async generateUuid(req, doc) { + if (!doc.uuid) { + doc.uuid = uuidv4(); + } + } + } + } + }, + apiRoutes(self) { + return { + get: { + async all(req) { + const projection = { + title: 1, + uuid: 1, + }; + try { + const platforms = await self.find(req).project(projection).toArray(); + return platforms; + } catch (error) { + throw self.apos.error('notfound', 'Platforms not found'); + } + } + } + } + } +}; \ No newline at end of file diff --git a/modules/policies/index.js b/modules/policies/index.js new file mode 100644 index 0000000..a5d79d1 --- /dev/null +++ b/modules/policies/index.js @@ -0,0 +1,18 @@ +const exn = require('../../lib/exn') + + +module.exports = { + apiRoutes(self) { + return { + post: { + async publish(req) { + try { + await exn.publish_policies(req.body.policies) + } catch (error) { + throw error; + } + }, + }, + }; + }, +}; diff --git a/modules/resources/index.js b/modules/resources/index.js new file mode 100644 index 0000000..9c721fb --- /dev/null +++ b/modules/resources/index.js @@ -0,0 +1,308 @@ +const { v4: uuidv4 } = require('uuid'); +const Joi = require('joi'); +const exn = require('../../lib/exn'); +const _ = require('lodash'); + +const projection = { + title: 1, + uuid: 1, + organization: 1, + platform: 1, + appId: 1, + appSecret: 1 +}; +const resourcesSchema = Joi.object({ + appId: Joi.string().required().messages({ + 'string.empty': 'App ID is required.', + 'any.required': 'App ID is a required field.' + }), + appSecret: Joi.string().required().messages({ + 'string.empty': 'App Secret is required.', + 'any.required': 'App Secret is a required field.' + }), + platform: Joi.string().required().messages({ + 'string.empty': 'Platform is required.', + 'any.required': 'Platform is a required field.' + }), +}).unknown().options({ abortEarly: false }); + +module.exports = { + extend: '@apostrophecms/piece-type', + options: { + label: 'Resource', + }, + fields: { + add: { + uuid: { + type: 'string', + label: 'UUID', + readOnly: true + }, + platform: { + type: 'string', + label: 'Platform' + }, + appId: { + type: 'string', + label: 'App ID', + }, + appSecret: { + type: 'string', + label: 'App Secret', + } + }, + group: { + basics: { + label: 'Details', + fields: ['title', 'uuid', 'platform', 'appId', 'appSecret'] + } + } + }, + handlers(self) { + async function generateUuid(doc) { + if (!doc.uuid) { + doc.uuid = uuidv4(); + } + } + async function assignOrganization(req, doc) { + if (req.user.role === "admin" && req.user.organization) { + doc.organization = req.user.organization; + } + } + return { + beforeInsert: { + + async handler(req, doc) { + if (!(req.user.role === "admin")){ + throw self.apos.error('forbidden', 'Editors are not allowed to create resources'); + } + await generateUuid(doc); + try{ + + if(doc.aposMode === 'published'){ + const message = await exn.register_cloud( + doc.uuid, + doc.appId, + doc.appSecret, + ) + console.log("Registered ",message); + + } + await self.updateWithPlatformInfo(doc); + await assignOrganization(req, doc); + + }catch(e){ + throw self.apos.error('invalid', 'Unknown Error '+e); + } + } + }, + + + beforeSave: { + async handler(req, doc, options) { + try { + self.validateDocument(doc); + } catch (error) { + if (error.name === 'required' && error.error && error.error.length > 0) { + const formattedErrors = error.error.map(err => { + return { field: err.path, message: err.message }; + }); + throw self.apos.error('invalid', 'Validation failed', { errors: formattedErrors }); + } else { + throw error; + } + } + } + } + } + }, + + methods(self) { + return { + async updateWithPlatformInfo(doc) { + if (doc.platform && !doc.platformUpdated) { + const platformPiece = await self.apos.doc.db.findOne({ + type: 'platforms', + uuid: doc.platform + }); + + if (platformPiece) { + doc.platform = platformPiece.title; + doc.platformUpdated = true; + } else { + throw self.apos.error('notfound', 'Platform not found'); + } + } + }, + + validateDocument(doc) { + const validateField = (data, schema) => { + const {error} = schema.validate(data); + if (error) { + const formattedErrors = error.details.map(detail => ({ + path: detail.path.join('.'), + message: detail.message.replace(/\"/g, "") + })); + throw self.apos.error('required', 'Validation failed', {error: formattedErrors}); + } + }; + validateField(doc, resourcesSchema); + } + } + }, + apiRoutes(self) { + return { + get: { + async all(req) { + + const currentUser = req.user; + const adminOrganization = currentUser.organization; + try { + const filters = { + organization: adminOrganization + }; + const resources = await self.find(req, filters).project(projection).toArray(); + return resources; + } catch (error) { + throw self.apos.error('notfound', 'Resource not found'); + } + }, + async ':uuid/uuid'(req) { + const uuid = req.params.uuid; + + if (!( req.user.organization)) { + throw self.apos.error('forbidden', 'You do not have permission to perform this action'); + } + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + try { + const doc = await self.find(req, { uuid: uuid , organization:adminOrganization}).project(projection).toObject(); + if (!doc) { + throw self.apos.error('notfound', 'Resource not found'); + } + + return doc; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + }, + async 'candidates'(req) { + + if (!( req.user.organization)) { + throw self.apos.error('forbidden', 'You do not have permission to perform this action'); + } + + try { + + const message = await exn.get_cloud_candidates() + return _.map(JSON.parse(message.body), (r)=>{ + return { + id: r.nodeId, + region: r.location.name, + instanceType: r.hardware.name, + virtualCores: r.hardware.cores, + memory: r.hardware.ram + } + }) + + + } catch (error) { + console.error(error) + throw self.apos.error(500, error); + } + } + }, + delete: { + async ':uuid/uuid'(req) { + const uuid = req.params.uuid; + + if (!uuid) { + throw self.apos.error('invalid', 'UUID is required'); + } + if (!(req.user.role === "admin" && req.user.organization)) { + throw self.apos.error('forbidden', 'You do not have permission to perform this action'); + } + + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + try { + const filters = { + uuid: uuid, + organization: adminOrganization + }; + + //Νo refactor here because we need both docs. + const docs = await self.apos.db.collection('aposDocs').find({ uuid: uuid, organization:adminOrganization }).toArray(); + + if (!docs || docs.length === 0) { + throw self.apos.error('notfound', 'Resource not found'); + } + + for (const doc of docs) { + if (doc.organization !== adminOrganization) { + throw self.apos.error('forbidden', 'Access denied'); + } + + await self.apos.db.collection('aposDocs').deleteOne({ uuid: doc.uuid }); + } + + return { status: 'success', message: 'Resource deleted successfully' }; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + } + }, + patch: { + async ':uuid/uuid'(req) { + const uuid = req.params.uuid; + const updateData = req.body; + + if (!(req.user.role === "admin" && req.user.organization)) { + throw self.apos.error('forbidden', 'You do not have permission to perform this action'); + } + self.validateDocument(updateData); + + const adminOrganization = req.user.organization; + + try { + const filters = { + uuid: uuid, + organization: adminOrganization + }; + const resourcesToUpdate = await self.find(req, filters).project(projection).toArray(); + + if (!resourcesToUpdate || resourcesToUpdate.length === 0) { + throw self.apos.error('notfound', 'Resource not found'); + } + + const doc = resourcesToUpdate[0]; + + + if ('platform' in updateData) { + let docToUpdate = { ...doc, ...updateData }; + await self.updateWithPlatformInfo(docToUpdate); + + await self.apos.doc.db.updateOne( + { uuid: uuid }, + { $set: docToUpdate } + ); + } else { + await self.apos.doc.db.updateOne( + { uuid: uuid }, + { $set: updateData } + ); + } + + const resourceUpdated = await self.find(req, filters).project(projection).toArray(); + + return resourceUpdated; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + } + } + } + } +}; diff --git a/modules/swagger/index.js b/modules/swagger/index.js new file mode 100644 index 0000000..3c68054 --- /dev/null +++ b/modules/swagger/index.js @@ -0,0 +1,27 @@ +const swaggerJSDoc = require('swagger-jsdoc'); +const swaggerUi = require('swagger-ui-express'); +const path = require('path'); + +module.exports = { + init(self) { + const swaggerDefinition = { + openapi: '3.0.0', + info: { + title: 'NebulOus API', + version: '1.0.0', + description: 'Documentation for NebulOus API', + } + }; + + const swaggerOptions = { + swaggerDefinition, + apis: [ + path.join(__dirname, 'swagger.yml'), + './modules/**/*.js', + ], + }; + const swaggerSpec = swaggerJSDoc(swaggerOptions); + + self.apos.app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); + } +}; diff --git a/modules/swagger/swagger.yml b/modules/swagger/swagger.yml new file mode 100644 index 0000000..ea9f4c5 --- /dev/null +++ b/modules/swagger/swagger.yml @@ -0,0 +1,759 @@ +swagger: '2.0' +info: + version: '1.0.0' + title: 'ApostropheCMS API' + description: API for managing various functionalities in ApostropheCMS, including user authentication, application management, and mathematical expression parsing. +tags: + - name: Authentication + description: Endpoints related to user authentication + - name: Application Management + description: Endpoints for managing Applications + - name: Math Parser + description: Endpoints for parsing mathematical expressions + - name: YAML Parser + description: Endpoints for processing YAML content and finding keys + - name: Resources + description: Endpoints for managing Resources +paths: + /api/v1/@apostrophecms/login/login: + post: + tags: + - Authentication + summary: Login to obtain a bearer token + description: Authenticates user credentials and provides a bearer token. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - username + - password + properties: + username: + type: string + password: + type: string + responses: + 200: + description: Successfully authenticated + content: + application/json: + schema: + type: object + properties: + token: + type: string + example: 'random123Token456xyz' + /api/v1/application/: + get: + tags: + - Application Management + summary: Retrieve All Applications + description: Retrieves a list of all applications. + responses: + 200: + description: Successfully retrieved list of applications. + 500: + description: Server error. + post: + tags: + - Application Management + summary: Create a New Application + description: Creates a new application. + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: uuid + in: query + required: false + schema: + type: string + - name: content + in: query + required: false + schema: + type: string + - name: variables + in: query + required: false + schema: + type: object + properties: + name: + type: string + lowerValue: + type: number + format: float + higherValue: + type: number + format: float + - name: constants + in: query + required: false + schema: + type: object + properties: + name: + type: string + number: + type: number + format: float + - name: Providers + in: query + required: false + schema: + type: object + properties: + name: + type: string + platform: + type: string + enabled: + type: boolean + - name: metrics + in: query + required: false + schema: + type: object + properties: + type: + type: string + enum: + - composite + - raw + nameResult: + type: string + isWindowResult: + type: string + outputResult: + type: string + nameComposite: + type: string + formula: + type: string + isWindow: + type: boolean + isWindowType: + type: string + enum: + - all + - sliding + interval: + type: integer + unit: + type: string + enum: + - ms + - sec + - min + - hour + - day + outputType: + type: string + enum: + - all + - sliding + outputInterval: + type: integer + outputUnit: + type: string + enum: + - ms + - sec + - min + - hour + - day + nameRaw: + type: string + sensor: + type: string + config: + type: array + items: + type: object + properties: + config1: + type: string + config2: + type: string + - name: sloViolations + in: query + required: false + schema: + type: object + properties: + LogicalOperator: + type: string + label: Logical Operator + enum: + - and + - or + - not + Name: + type: string + label: Name + Operator: + type: string + label: Operator + enum: + - ">" + - "<" + - "<=" + - ">=" + - "==" + - "!==" + Value: + type: integer + label: Value + SubExpressionsLogicalOperator: + type: string + label: Logical Operator (Sub Expressions) + enum: + - and + - or + - not + SubExpressionsName: + type: string + label: Name (Sub Expressions) + SubExpressionsOperator: + type: string + label: Operator (Sub Expressions) + enum: + - ">" + - "<" + - "<=" + - ">=" + - "==" + - "!==" + SubExpressionsValue: + type: integer + label: Value (Sub Expressions) + - name: utilityFunctions + in: query + required: false + schema: + type: object + properties: + functionName: + type: string + label: Function Name + functionType: + type: string + label: Function Type + enum: + - maximize + - constant + functionDetails: + type: string + label: Function Details + functionExpression: + type: string + label: Function Expression + functionExpressionVariables: + type: array + items: + type: object + properties: + nameVariable: + type: string + valueVariable: + type: string + + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + uuid: + type: string + readOnly: true + content: + type: string + variables: + type: array + items: + type: object + properties: + name: + type: string + firstNumber: + type: number + format: float + secondNumber: + type: number + format: float + constants: + type: array + items: + type: object + properties: + name: + type: string + number: + type: number + format: float + providers: + type: array + items: + type: object + properties: + name: + type: string + platform: + type: string + enabled: + type: boolean + metrics: + type: array + items: + type: object + properties: + type: + type: string + enum: + - composite + - raw + nameResult: + type: string + isWindowResult: + type: string + outputResult: + type: string + nameComposite: + type: string + formula: + type: string + isWindow: + type: boolean + isWindowType: + type: string + enum: + - all + - sliding + interval: + type: integer + unit: + type: string + enum: + - ms + - sec + - min + - hour + - day + output: + type: object + properties: + typeOutput: + type: string + enum: + - all + - sliding + intervalOutput: + type: integer + unitOoutput: + type: string + enum: + - ms + - sec + - min + - hour + - day + nameRaw: + type: string + sensor: + type: string + config: + type: array + items: + type: object + properties: + config1: + type: string + config2: + type: string + sloViolations: + type: array + items: + type: object + properties: + logicalOperator: + type: string + enum: + - and + - or + - not + name: + type: string + operator: + type: string + enum: + - ">" + - "<" + - "<=" + - ">=" + - "==" + - "!==" + value: + type: integer + subExpressions: + type: array + items: + type: object + properties: + logicalOperator: + type: string + enum: + - and + - or + - not + name: + type: string + operator: + type: string + enum: + - ">" + - "<" + - "<=" + - ">=" + - "==" + - "!==" + value: + type: integer + utilityFunctions: + type: array + items: + type: object + properties: + functionName: + type: string + label: Function Name + functionType: + type: string + label: Function Type + enum: + - maximize + - constant + functionDetails: + type: string + label: Function Details + textarea: true + functionExpression: + type: string + label: Function Expression + textarea: true + functionExpressionVariables: + type: array + items: + type: object + properties: + nameVariable: + type: string + valueVariable: + type: string + responses: + 201: + description: Successfully created a new application. + 400: + description: Invalid data format. + 500: + description: Server error. + /api/v1/application/{uuid}: + get: + tags: + - Application Management + summary: Retrieve a Specific Application + description: Retrieves a specific application by its UUID. + parameters: + - name: uuid + in: path + required: true + schema: + type: string + responses: + 200: + description: Successfully retrieved the application. + 404: + description: Application not found. + 500: + description: Server error. + delete: + tags: + - Application Management + summary: Delete a Specific Application + description: Deletes a specific application by its UUID. + parameters: + - name: UUID + in: path + required: true + schema: + type: string + responses: + 200: + description: Successfully deleted the application. + 404: + description: Application not found. + 500: + description: Server error. + + + /api/v1/mathparser/expression: + post: + tags: + - Math Parser + summary: Parses a mathematical equation and extracts variable names. + description: Receives a mathematical equation in string format and returns the names of variables used in the equation. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + equation: + type: string + description: The mathematical equation to be parsed. + example: "x^2 + y - 3" + responses: + 200: + description: A list of variable names found in the equation. + content: + application/json: + schema: + type: object + properties: + variables: + type: array + items: + type: string + description: Names of variables in the equation. + 400: + description: Error message if the equation is missing or invalid. + 500: + description: Internal server error. + /api/v1/kubevela/keys: + post: + tags: + - YAML Parser + summary: Processes YAML content and finds keys based on an optional query. + description: | + This endpoint accepts a string of YAML content and an optional query string. + It processes the YAML to extract a flat list of keys that match the query. + The query filters keys based on the provided string, returning only those that start with the query. + It returns a list of complete key paths without array indices. + parameters: + - in: query + name: q + required: false + description: Optional query string to filter the keys. It should be a dot-separated path prefix to filter the keys in the YAML content. + schema: + type: string + example: "spec.comp" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - content + properties: + content: + type: string + description: YAML content in string format. + example: | + apiVersion: core.oam.dev/v1beta1 + kind: Application + metadata: + name: velaux + namespace: vela-system + spec: + components: + - name: namespace + type: k8s-objects + properties: + objects: + - apiVersion: v1 + kind: Namespace + metadata: + name: my-namespace + responses: + 200: + description: Successfully processed the YAML content and returns a list of matching keys. + content: + application/json: + schema: + type: object + properties: + keys: + type: array + items: + type: string + description: List of keys found in the YAML content matching the query. Keys are complete paths without array indices. + 400: + description: Bad request, returned when YAML content is not provided or is invalid. + 404: + description: Not found, returned when no matching keys are found based on the provided query. + 500: + description: Internal server error. + /api/v1/userapi/all: + get: + tags: + - Authentication + summary: Get All Users + description: Retrieve a list of all users. + responses: + 200: + description: Successful operation. Returns a list of users. + 403: + description: Insufficient permissions. + 500: + description: Server error. + + /api/v1/userapi/create-user: + post: + tags: + - Authentication + summary: Create a User + description: Create a new user. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + password: + type: string + email: + type: string + required: + - username + - password + - email + responses: + 200: + description: User created successfully. + 400: + description: Invalid or missing required fields. + 403: + description: Insufficient permissions. + 500: + description: Server error. + /api/v1/userapi/{id}: + delete: + tags: + - Authentication + summary: Delete a User + description: Delete a user by their ID. + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + 200: + description: User deleted successfully. + 403: + description: Insufficient permissions. + 404: + description: User not found. + 500: + description: Server error. + /api/v1/resources: + get: + tags: + - Resources + summary: Get all Resources + description: Retrieve a list of all Resources. + responses: + 200: + description: Array of Resources. + 500: + description: Server error. + + post: + tags: + - Resources + summary: Create a new Resource + description: Add a new Resource to the system. + parameters: + - in: body + name: Resources + description: Resources object + required: true + schema: + $ref: '#/definitions/Resources' + responses: + 200: + description: Resource created successfully. + 400: + description: Invalid input. + 500: + description: Server error. + + /api/v1/resources/{uuid}: + get: + tags: + - Resources + summary: Get a specific Resource + description: Retrieve a specific Resource by their UUID. + parameters: + - name: uuid + in: path + required: true + schema: + type: string + responses: + 200: + description: Resource details. + 404: + description: Resource not found. + 500: + description: Server error. + + delete: + tags: + - Resources + summary: Delete a Resource + description: Delete a specific Resource by their UUID. + parameters: + - name: uuid + in: path + required: true + schema: + type: string + responses: + 200: + description: Resource deleted successfully. + 404: + description: Resource not found. + 500: + description: Server error. + +definitions: + Resources: + type: object + required: + - title + - platform + - appId + - appSecret + properties: + title: + type: string + platform: + type: string + appId: + type: string + appSecret: + type: string diff --git a/modules/userapi/index.js b/modules/userapi/index.js new file mode 100644 index 0000000..b73679e --- /dev/null +++ b/modules/userapi/index.js @@ -0,0 +1,285 @@ +const { v4: uuidv4 } = require('uuid'); + +module.exports = { + handlers(self) { + return { + beforeInsert: { + async generateUuid(req, doc, options) { + if (!doc.uuid) { + doc.uuid = uuidv4(); + } + } + }, + } + }, + apiRoutes(self) { + return { + get: { + async all(req) { + if (!self.apos.permission.can(req, 'admin')) { + throw self.apos.error('forbidden', 'Insufficient permissions'); + } + const currentUser = req.user; + const adminOrganization = currentUser.organization; + const filters = {}; + + filters.createdBy = currentUser._id; + if (adminOrganization) { + filters.organization = adminOrganization; + } + + try { + const users = await self.apos.user.find(req, filters).toArray(); + return users; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + }, + async 'me'(req) { + const currentUser = req.user; + if (!currentUser) { + throw self.apos.error('forbidden', 'You must be logged in to access this information'); + } + + try { + const user = await self.apos.user.find(req, { uuid: currentUser.uuid }).toObject(); + + if (!user) { + throw self.apos.error('notfound', 'User not found'); + } + + return user; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + }, + async ':uuid/uuid'(req) { + if (!self.apos.permission.can(req, 'admin')) { + throw self.apos.error('forbidden', 'Insufficient permissions'); + } + + const userId = req.params.uuid; + if (!userId) { + throw self.apos.error('invalid', 'User UUID is required'); + } + + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + try { + const user = await self.apos.user.find(req, { uuid: userId, organization:adminOrganization }).toObject(); + + if (!user) { + throw self.apos.error('notfound', 'User not found'); + } + + if (user.organization !== adminOrganization) { + throw self.apos.error('forbidden', 'Access denied'); + } + + return user; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + }, + }, + post: { + async 'create-user'(req) { + + if (!self.apos.permission.can(req, 'admin')) { + throw self.apos.error('forbidden', 'Insufficient permissions'); + } + + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + const {username, password, email, firstname, lastname} = req.body; + if (!username || !password || !email) { + throw self.apos.error('invalid', 'Missing required fields'); + } + + try { + const username = req.body.username; + const userData = { + username: username, + title: username, + password: password, + email: email, + firstname: firstname, + lastname: lastname, + uuid: uuidv4(), + role: 'editor', + slug: `user-${username}`, + createdBy: currentUser._id, + organization: adminOrganization, + }; + + const user = await self.apos.user.insert(req, userData); + + return user; + } catch (error) { + throw self.apos.error('invalid', error.message); + } + } + }, + delete: { + async ':uuid/uuid'(req) { + const userId = req.params.uuid; + + if (!userId) { + throw self.apos.error('invalid', 'User UUID is required'); + } + + const currentUser = req.user; + const adminOrganization = currentUser.organization; + const userToDelete = await self.apos.user.find(req, {uuid: userId, organization:adminOrganization}).toObject(); + + if (!userToDelete) { + throw self.apos.error('notfound', 'User not found'); + } + + if (userToDelete.organization !== adminOrganization) { + throw self.apos.error('forbidden', 'Access denied'); + } + + if (!self.apos.permission.can(req, 'admin') && userToDelete.createdBy !== currentUser.uuid) { + throw self.apos.error('forbidden', 'You do not have permission to perform this action.'); + } + + try { + await self.apos.doc.db.deleteOne({uuid: userId}); + + if (self.apos.db.collection('aposUsersSafe')) { + await self.apos.db.collection('aposUsersSafe').deleteOne({uuid: userId}); + } + + return {message: 'User deleted successfully'}; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + } + }, + patch: { + async 'profile-update'(req) { + const currentUser = req.user; + const updateData = req.body; + + try { + const updateFields = { ...updateData }; + delete updateFields.password; + + if (updateFields.username) { + updateFields.title = updateFields.username; + updateFields.slug = `user-${updateFields.username}`; + + await self.apos.doc.db.updateOne( + {uuid: currentUser.uuid}, + { + $set: { + username: updateFields.username, + title: updateFields.username, + slug: `user-${updateFields.username}` + } + } + ); + } + // Update other fields in aposDocs + if (Object.keys(updateFields).length > 0) { + await self.apos.doc.db.updateOne( + { uuid: currentUser.uuid }, + { $set: updateFields } + ); + } + + await self.apos.db.collection('aposUserSafe').updateOne( + { _id: currentUser._id }, + { $set: { username: updateData.username } } + ); + + if (updateData.password) { + const newPassword = updateData.password; + + const hashedPassword = await self.apos.user.hashPassword(newPassword); + + await self.apos.db.collection('aposUserSafe').updateOne( + { _id: currentUser._id }, + { $set: { password: hashedPassword } } + ); + } + + const updatedUser = await self.apos.user.find(req, { uuid: currentUser.uuid }).toObject(); + + return updatedUser; + } catch (error) { + throw self.apos.error(error.name, error.message); + } + }, + async ':uuid/uuid'(req) { + const userId = req.params.uuid; + const updateData = req.body; + + if (!self.apos.permission.can(req, 'admin')) { + throw self.apos.error('forbidden', 'Insufficient permissions'); + } + + const currentUser = req.user; + const adminOrganization = currentUser.organization; + + try { + + const updateFields = { ...updateData }; + delete updateFields.password; + + const userToUpdate = await self.apos.user.find(req, { uuid: userId, organization:adminOrganization}).toObject(); + if (!userToUpdate) { + throw self.apos.error('notfound', 'User not found'); + } + if (userToUpdate.organization !== adminOrganization) { + throw self.apos.error('forbidden', 'You can only update users within your organization'); + } + + if (updateFields.username) { + updateFields.title = updateFields.username; + updateFields.slug = `user-${updateFields.username}`; + + await self.apos.doc.db.updateOne( + { uuid: userId }, + { $set: { username: updateFields.username, title: updateFields.username, slug: `user-${updateFields.username}` } } + ); + + await self.apos.db.collection('aposUserSafe').updateOne( + { _id: userToUpdate._id }, + { $set: { username: updateFields.username } } + ); + } + + if (Object.keys(updateFields).length > 0) { + await self.apos.doc.db.updateOne( + { uuid: userId }, + { $set: updateFields } + ); + } + if (updateData.password) { + const newPassword = updateData.password; + + const hashedPassword = await self.apos.user.hashPassword(newPassword); + + await self.apos.db.collection('aposUserSafe').updateOne( + { _id: userToUpdate._id }, + { $set: { password: hashedPassword } } + ); + } + + const updatedUser = await self.apos.user.find(req, { uuid: userId }).toObject(); + + return updatedUser; + + } catch (error) { + throw self.apos.error(error.name, error.message); + } + } + } + + }; + } +}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..04aa088 --- /dev/null +++ b/package.json @@ -0,0 +1,61 @@ +{ + "name": "apos-app", + "version": "1.0.0", + "description": "Apostrophe 3 Essential Starter Kit Site", + "main": "app.js", + "scripts": { + "start": "node app", + "dev": "nodemon", + "build": "bash ./scripts/heroku-release-tasks", + "serve": "NODE_ENV=production node app", + "release": "npm install && npm run build && node app @apostrophecms/migration:migrate" + }, + "nodemonConfig": { + "delay": 1000, + "verbose": true, + "watch": [ + "./app.js", + "./modules/**/*", + "./lib/**/*.js", + "./views/**/*.html" + ], + "ignoreRoot": [ + ".git" + ], + "ignore": [ + "**/ui/apos/", + "**/ui/src/", + "**/ui/public/", + "locales/*.json", + "public/uploads/", + "public/apos-frontend/*.js", + "data/" + ], + "ext": "json, js, html, scss, vue" + }, + "repository": { + "type": "git", + "url": "https://github.com/apostrophecms/starter-kit-essentials" + }, + "author": "Apostrophe Technologies, Inc.", + "license": "MIT", + "dependencies": { + "@johmun/vue-tags-input": "^2.1.0", + "apostrophe": "^3.61.1", + "flat": "^5.0.2", + "joi": "^17.11.0", + "mathjs": "^12.2.1", + "normalize.css": "^8.0.1", + "rhea": "^3.0.2", + "slugify": "^1.6.6", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.0", + "uuid": "^9.0.1", + "yaml": "^2.3.4" + }, + "devDependencies": { + "eslint": "^8.0.0", + "eslint-config-apostrophe": "^4.0.0", + "nodemon": "^3.0.1" + } +} diff --git a/public/css/master-anon-clrghj9c60000fzwdfdwkrwpn.less b/public/css/master-anon-clrghj9c60000fzwdfdwkrwpn.less new file mode 100644 index 0000000..eb50441 --- /dev/null +++ b/public/css/master-anon-clrghj9c60000fzwdfdwkrwpn.less @@ -0,0 +1,10 @@ +@import '../modules/apostrophe-assets/css/vendor/jquery-ui.less'; +@import '../modules/apostrophe-assets/css/vendor/pikaday.less'; +@import '../modules/apostrophe-login/css/always.less'; +@import '../modules/apostrophe-ui/css/always.less'; +@import '../modules/apostrophe-ui/css/vendor/font-awesome/font-awesome.less'; +@import '../modules/apostrophe-modal/css/user.less'; +@import '../modules/apostrophe-oembed/css/always.less'; +@import '../modules/apostrophe-areas/css/user.less'; +@import '../modules/apostrophe-pieces-widgets/css/always.less'; +@import '../modules/apostrophe-images-widgets/css/always.less'; \ No newline at end of file diff --git a/public/css/master-user-clrghj9c60000fzwdfdwkrwpn.less b/public/css/master-user-clrghj9c60000fzwdfdwkrwpn.less new file mode 100644 index 0000000..05881b3 --- /dev/null +++ b/public/css/master-user-clrghj9c60000fzwdfdwkrwpn.less @@ -0,0 +1,30 @@ +@import '../modules/apostrophe-assets/css/vendor/jquery-ui.less'; +@import '../modules/apostrophe-assets/css/vendor/pikaday.less'; +@import '../modules/apostrophe-assets/css/vendor/cropper.less'; +@import '../modules/apostrophe-assets/css/vendor/spectrum.less'; +@import '../modules/apostrophe-login/css/always.less'; +@import '../modules/apostrophe-login/css/user.less'; +@import '../modules/apostrophe-notifications/css/user.less'; +@import '../modules/apostrophe-ui/css/always.less'; +@import '../modules/apostrophe-ui/css/vendor/font-awesome/font-awesome.less'; +@import '../modules/apostrophe-ui/css/user.less'; +@import '../modules/apostrophe-schemas/css/user.less'; +@import '../modules/apostrophe-jobs/css/user.less'; +@import '../modules/apostrophe-versions/css/user.less'; +@import '../modules/apostrophe-tags/css/user.less'; +@import '../modules/apostrophe-modal/css/user.less'; +@import '../modules/apostrophe-attachments/css/user.less'; +@import '../modules/apostrophe-oembed/css/always.less'; +@import '../modules/apostrophe-pager/css/user.less'; +@import '../modules/apostrophe-doc-type-manager/css/chooser.less'; +@import '../modules/apostrophe-pieces/css/manager.less'; +@import '../modules/apostrophe-polymorphic-manager/css/polymorphic-manager.less'; +@import '../modules/apostrophe-pages/css/jqtree.less'; +@import '../modules/apostrophe-pages/css/user.less'; +@import '../modules/apostrophe-areas/css/user.less'; +@import '../modules/apostrophe-rich-text-widgets/css/user.less'; +@import '../modules/apostrophe-video-fields/css/user.less'; +@import '../modules/apostrophe-images/css/user.less'; +@import '../modules/apostrophe-images-widgets/css/user.less'; +@import '../modules/apostrophe-pieces-widgets/css/always.less'; +@import '../modules/apostrophe-images-widgets/css/always.less'; \ No newline at end of file diff --git a/public/images/logo.png b/public/images/logo.png new file mode 100644 index 0000000..68ddd4f Binary files /dev/null and b/public/images/logo.png differ diff --git a/scripts/build-assets.sh b/scripts/build-assets.sh new file mode 100755 index 0000000..c84e53c --- /dev/null +++ b/scripts/build-assets.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +export APOS_RELEASE_ID=`cat /dev/urandom |env LC_CTYPE=C tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1` + +echo $APOS_RELEASE_ID > ./release-id + +node app @apostrophecms/asset:build \ No newline at end of file diff --git a/views/layout.html b/views/layout.html new file mode 100644 index 0000000..5d2a914 --- /dev/null +++ b/views/layout.html @@ -0,0 +1,27 @@ +{# Automatically extends the right outer layout and also handles AJAX siutations #} +{% extends data.outerLayout %} + +{% set title = data.piece.title or data.page.title %} +{% block title %} + {{ title }} + {% if not title %} + {{ apos.log('Looks like you forgot to override the title block in a template that does not have access to an Apostrophe page or piece.') }} + {% endif %} +{% endblock %} + +{% block beforeMain %} + +{% endblock %} + +{% block main %} + {# + Usually, your page templates in the @apostrophecms/pages module will override + this block. It is safe to assume this is where your page-specific content + should go. + #} +{% endblock %} + +{% block afterMain %} + + +{% endblock %}