initial implementation of community packages
This commit is contained in:
2
CHANGES
2
CHANGES
@@ -3130,4 +3130,6 @@
|
||||
* Update redis to 8.4.0
|
||||
* Add notification view
|
||||
* updater: skip backup site check when user skips backup
|
||||
* community packages
|
||||
* source builds
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { prettyDate, prettyBinarySize } from '@cloudron/pankow/utils';
|
||||
import AccessControl from './AccessControl.vue';
|
||||
import PortBindings from './PortBindings.vue';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import AppstoreModel from '../models/AppstoreModel.js';
|
||||
import DomainsModel from '../models/DomainsModel.js';
|
||||
import UsersModel from '../models/UsersModel.js';
|
||||
import GroupsModel from '../models/GroupsModel.js';
|
||||
@@ -19,7 +18,6 @@ const STEP = Object.freeze({
|
||||
INSTALL: Symbol('install'),
|
||||
});
|
||||
|
||||
const appstoreModel = AppstoreModel.create();
|
||||
const appsModel = AppsModel.create();
|
||||
const domainsModel = DomainsModel.create();
|
||||
const usersModel = UsersModel.create();
|
||||
@@ -210,35 +208,15 @@ function onScreenshotNext() {
|
||||
elem.scrollIntoView({ behavior: 'smooth', inline: 'start', block: 'nearest' });
|
||||
}
|
||||
|
||||
async function getApp(id, version = '') {
|
||||
const [error, result] = await appstoreModel.get(id, version);
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open: async function(appId, version, appCountExceeded, domainList) {
|
||||
open: function(appData, appCountExceeded, domainList) {
|
||||
busy.value = false;
|
||||
step.value = STEP.LOADING;
|
||||
step.value = STEP.DETAILS;
|
||||
formError.value = {};
|
||||
|
||||
// give it some time to fetch before showing loading
|
||||
const openTimer = setTimeout(dialog.value.open, 200);
|
||||
|
||||
const a = await getApp(appId, version);
|
||||
if (!a) {
|
||||
clearTimeout(openTimer);
|
||||
dialog.value.close();
|
||||
throw new Error('app not found');
|
||||
}
|
||||
|
||||
app.value = a;
|
||||
app.value = appData;
|
||||
appMaxCountExceeded.value = appCountExceeded;
|
||||
manifest.value = a.manifest;
|
||||
manifest.value = appData.manifest;
|
||||
location.value = '';
|
||||
accessRestrictionOption.value = ACL_OPTIONS.ANY;
|
||||
accessRestrictionAcl.value = { users: [], groups: [] };
|
||||
@@ -255,8 +233,8 @@ defineExpose({
|
||||
// preselect with dashboard domain
|
||||
domain.value = domains.value.find(d => d.domain === dashboardDomain.value).domain;
|
||||
|
||||
tcpPorts.value = a.manifest.tcpPorts;
|
||||
udpPorts.value = a.manifest.udpPorts;
|
||||
tcpPorts.value = appData.manifest.tcpPorts;
|
||||
udpPorts.value = appData.manifest.udpPorts;
|
||||
|
||||
// ensure we have value property
|
||||
for (const p in tcpPorts.value) {
|
||||
@@ -268,7 +246,7 @@ defineExpose({
|
||||
udpPorts.value[p].enabled = udpPorts.value[p].enabledByDefault ?? true;
|
||||
}
|
||||
|
||||
secondaryDomains.value = a.manifest.httpPorts;
|
||||
secondaryDomains.value = appData.manifest.httpPorts;
|
||||
for (const p in secondaryDomains.value) {
|
||||
const port = secondaryDomains.value[p];
|
||||
port.value = port.defaultValue;
|
||||
@@ -276,7 +254,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
currentScreenshotPos = 0;
|
||||
step.value = STEP.DETAILS;
|
||||
dialog.value.open();
|
||||
},
|
||||
close() {
|
||||
dialog.value.close();
|
||||
|
||||
75
dashboard/src/components/CommunityAppDialog.vue
Normal file
75
dashboard/src/components/CommunityAppDialog.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Dialog, TextInput, FormGroup } from '@cloudron/pankow';
|
||||
import CommunityModel from '../models/CommunityModel.js';
|
||||
|
||||
const communityModel = CommunityModel.create();
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
|
||||
const busy = ref(false);
|
||||
const formError = ref({});
|
||||
const url = ref('');
|
||||
const version = ref('latest');
|
||||
|
||||
async function onSubmit() {
|
||||
busy.value = true;
|
||||
formError.value = {};
|
||||
|
||||
const [error, result] = await communityModel.getApp(url.value, version.value);
|
||||
if (error) {
|
||||
formError.value.generic = error.body?.message || 'Failed to fetch community app';
|
||||
busy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
emit('success', result);
|
||||
dialog.value.close();
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open() {
|
||||
url.value = '';
|
||||
version.value = 'latest';
|
||||
formError.value = {};
|
||||
busy.value = false;
|
||||
dialog.value.open();
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
title="Install Community App"
|
||||
confirm-label="Continue"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy && url !== ''"
|
||||
reject-style="secondary"
|
||||
reject-label="Cancel"
|
||||
:reject-active="!busy"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" />
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<FormGroup>
|
||||
<label for="urlInput">CloudronVersions.json URL</label>
|
||||
<TextInput id="urlInput" v-model="url" required placeholder="https://example.com/CloudronVersions.json"/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="versionInput">Version</label>
|
||||
<TextInput id="versionInput" v-model="version" required/>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Dialog>
|
||||
</template>
|
||||
25
dashboard/src/models/CommunityModel.js
Normal file
25
dashboard/src/models/CommunityModel.js
Normal file
@@ -0,0 +1,25 @@
|
||||
|
||||
import { fetcher } from '@cloudron/pankow';
|
||||
import { API_ORIGIN } from '../constants.js';
|
||||
|
||||
function create() {
|
||||
const accessToken = localStorage.token;
|
||||
|
||||
return {
|
||||
async getApp(url, version) {
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/community/app`, { access_token: accessToken, url, version });
|
||||
} catch (e) {
|
||||
return [e];
|
||||
}
|
||||
|
||||
if (result.status !== 200) return [result];
|
||||
return [null, result.body];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
create,
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import DomainsModel from '../models/DomainsModel.js';
|
||||
import ApplinkDialog from '../components/ApplinkDialog.vue';
|
||||
import AppInstallDialog from '../components/AppInstallDialog.vue';
|
||||
import AppStoreItem from '../components/AppStoreItem.vue';
|
||||
import CommunityAppDialog from '../components/CommunityAppDialog.vue';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const appstoreModel = AppstoreModel.create();
|
||||
@@ -143,16 +144,17 @@ async function onHashChange() {
|
||||
const params = new URLSearchParams(window.location.hash.slice(window.location.hash.indexOf('?')));
|
||||
const version = params.get('version') || 'latest';
|
||||
|
||||
try {
|
||||
await appInstallDialog.value.open(appId, version, installedApps.value.length >= features.value.appMaxCount, domains.value);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch (e) {
|
||||
inputDialog.value.info({
|
||||
const [error, appData] = await appstoreModel.get(appId, version);
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return inputDialog.value.info({
|
||||
title: t('appstore.appNotFoundDialog.title'),
|
||||
message: t('appstore.appNotFoundDialog.description', { appId, version }),
|
||||
confirmLabel: t('main.dialog.close'),
|
||||
});
|
||||
}
|
||||
|
||||
appInstallDialog.value.open(appData, installedApps.value.length >= features.value.appMaxCount, domains.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +180,7 @@ async function getDomains() {
|
||||
domains.value = result;
|
||||
}
|
||||
const applinkDialog = useTemplateRef('applinkDialog');
|
||||
const communityAppDialog = useTemplateRef('communityAppDialog');
|
||||
|
||||
function onAddAppLink() {
|
||||
applinkDialog.value.open();
|
||||
@@ -187,6 +190,14 @@ function onApplinkAdded() {
|
||||
window.location.href = '#/apps';
|
||||
}
|
||||
|
||||
function onInstallCommunityApp() {
|
||||
communityAppDialog.value.open();
|
||||
}
|
||||
|
||||
function onCommunityAppSuccess(appData) {
|
||||
appInstallDialog.value.open(appData, installedApps.value.length >= features.value.appMaxCount, domains.value);
|
||||
}
|
||||
|
||||
onActivated(async () => {
|
||||
setItemWidth();
|
||||
|
||||
@@ -222,6 +233,7 @@ onDeactivated(() => {
|
||||
<div ref="view" class="content-large" style="width: 100%; height: 100%;">
|
||||
<InputDialog ref="inputDialog"/>
|
||||
<ApplinkDialog ref="applinkDialog" @success="onApplinkAdded"/>
|
||||
<CommunityAppDialog ref="communityAppDialog" @success="onCommunityAppSuccess"/>
|
||||
<AppInstallDialog ref="appInstallDialog" @close="onAppInstallDialogClose"/>
|
||||
|
||||
<div class="filter-bar">
|
||||
@@ -229,6 +241,7 @@ onDeactivated(() => {
|
||||
<TextInput ref="searchInput" @keydown.esc="search = ''" v-model="search" :disabled="!ready" :placeholder="$t('appstore.searchPlaceholder')" style="flex-grow: 1;" autocomplete="off"/>
|
||||
<Button tool outline href="/#/appstore/io.cloudron.builtin.appproxy">Add app proxy</Button>
|
||||
<Button tool outline @click="onAddAppLink()">Add external link</Button>
|
||||
<Button tool outline @click="onInstallCommunityApp()">Install Community App</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="!ready" style="margin-top: 15px">
|
||||
|
||||
79
package-lock.json
generated
79
package-lock.json
generated
@@ -12,7 +12,7 @@
|
||||
"@aws-sdk/client-s3": "^3.974.0",
|
||||
"@aws-sdk/lib-storage": "^3.974.0",
|
||||
"@cloudron/connect-lastmile": "^2.3.0",
|
||||
"@cloudron/manifest-format": "^5.29.0",
|
||||
"@cloudron/manifest-format": "^5.31.0",
|
||||
"@cloudron/pipework": "^1.2.0",
|
||||
"@cloudron/superagent": "^1.0.1",
|
||||
"@google-cloud/dns": "^5.3.1",
|
||||
@@ -1291,18 +1291,39 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cloudron/manifest-format": {
|
||||
"version": "5.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudron/manifest-format/-/manifest-format-5.29.0.tgz",
|
||||
"integrity": "sha512-F0+pZ/ibs6jZAEXa0mKQBcFMLG4zmz4Qjkdx8irM4/1kbkIcvKTBaU1oRt6Uz8F7LSvlc1/D5sK45JKEkrNlCQ==",
|
||||
"version": "5.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudron/manifest-format/-/manifest-format-5.31.0.tgz",
|
||||
"integrity": "sha512-WzmUzKWyvtv3iR4dw2fyCbltgMk0dSTol7pAER47XZ4HTsJuQ4dpbR5/tFJ11P4DZudUJErOTf0nBkz50foQCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cron": "^4.3.1",
|
||||
"ajv": "^8.17.1",
|
||||
"cron": "^4.4.0",
|
||||
"safetydance": "2.5.1",
|
||||
"semver": "^7.7.2",
|
||||
"tv4": "^1.3.0",
|
||||
"validator": "^13.15.15"
|
||||
"semver": "^7.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudron/manifest-format/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudron/manifest-format/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cloudron/pipework": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudron/pipework/-/pipework-1.2.0.tgz",
|
||||
@@ -4913,7 +4934,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-fifo": {
|
||||
@@ -4934,6 +4954,22 @@
|
||||
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.1.tgz",
|
||||
@@ -8319,22 +8355,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tv4": {
|
||||
"version": "1.3.0",
|
||||
"license": [
|
||||
{
|
||||
"type": "Public Domain",
|
||||
"url": "http://geraintluff.github.io/tv4/LICENSE.txt"
|
||||
},
|
||||
{
|
||||
"type": "MIT",
|
||||
"url": "http://jsonary.com/LICENSE.txt"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "0.14.5",
|
||||
"license": "Unlicense"
|
||||
@@ -8522,15 +8542,6 @@
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/validator": {
|
||||
"version": "13.15.15",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz",
|
||||
"integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"@aws-sdk/client-s3": "^3.974.0",
|
||||
"@aws-sdk/lib-storage": "^3.974.0",
|
||||
"@cloudron/connect-lastmile": "^2.3.0",
|
||||
"@cloudron/manifest-format": "^5.29.0",
|
||||
"@cloudron/manifest-format": "^5.31.0",
|
||||
"@cloudron/pipework": "^1.2.0",
|
||||
"@cloudron/superagent": "^1.0.1",
|
||||
"@google-cloud/dns": "^5.3.1",
|
||||
|
||||
39
src/community.js
Normal file
39
src/community.js
Normal file
@@ -0,0 +1,39 @@
|
||||
'use strict';
|
||||
|
||||
exports = module.exports = {
|
||||
getAppVersion
|
||||
};
|
||||
|
||||
const assert = require('node:assert'),
|
||||
BoxError = require('./boxerror.js'),
|
||||
manifestFormat = require('@cloudron/manifest-format'),
|
||||
safe = require('safetydance'),
|
||||
superagent = require('@cloudron/superagent');
|
||||
|
||||
async function getAppVersion(url, version) {
|
||||
assert.strictEqual(typeof url, 'string');
|
||||
assert.strictEqual(typeof version, 'string');
|
||||
|
||||
if (!url.startsWith('https://')) throw new BoxError(BoxError.BAD_FIELD, 'URL must use HTTPS');
|
||||
|
||||
const [error, response] = await safe(superagent.get(url).timeout(60 * 1000).ok(() => true));
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
|
||||
if (response.status === 404) throw new BoxError(BoxError.NOT_FOUND, 'CloudronVersions.json not found');
|
||||
if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Fetch failed: ${response.status}`);
|
||||
|
||||
const versions = response.body;
|
||||
if (!versions || typeof versions !== 'object') throw new BoxError(BoxError.EXTERNAL_ERROR, 'Invalid CloudronVersions.json format');
|
||||
|
||||
const sortedVersions = Object.keys(versions).sort(manifestFormat.packageVersionCompare);
|
||||
const versionData = version === 'latest' ? versions[sortedVersions.at(-1)] : versions[version];
|
||||
if (!versionData) throw new BoxError(BoxError.NOT_FOUND, `Version ${version} not found`);
|
||||
|
||||
const manifestError = manifestFormat.checkVersionsRequirements(versionData.manifest);
|
||||
if (manifestError) throw new BoxError(BoxError.BAD_FIELD, `Invalid manifest: ${manifestError.message}`);
|
||||
|
||||
return {
|
||||
id: versionData.manifest.id,
|
||||
iconUrl: versionData.manifest.iconUrl,
|
||||
...versionData // { manifest, publishState, creationDate, ts }
|
||||
};
|
||||
}
|
||||
21
src/routes/community.js
Normal file
21
src/routes/community.js
Normal file
@@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
|
||||
exports = module.exports = {
|
||||
getAppVersion
|
||||
};
|
||||
|
||||
const assert = require('node:assert'),
|
||||
BoxError = require('../boxerror.js'),
|
||||
community = require('../community.js'),
|
||||
HttpSuccess = require('@cloudron/connect-lastmile').HttpSuccess,
|
||||
safe = require('safetydance');
|
||||
|
||||
async function getAppVersion(req, res, next) {
|
||||
assert.strictEqual(typeof req.query.url, 'string');
|
||||
assert.strictEqual(typeof req.query.version, 'string');
|
||||
|
||||
const [error, result] = await safe(community.getAppVersion(req.query.url, req.query.version));
|
||||
if (error) return next(BoxError.toHttpError(error));
|
||||
|
||||
next(new HttpSuccess(200, result));
|
||||
}
|
||||
@@ -12,6 +12,7 @@ exports = module.exports = {
|
||||
backupSites: require('./backupsites.js'),
|
||||
branding: require('./branding.js'),
|
||||
cloudron: require('./cloudron.js'),
|
||||
community: require('./community.js'),
|
||||
dashboard: require('./dashboard.js'),
|
||||
directoryServer: require('./directoryserver.js'),
|
||||
dockerRegistries: require('./dockerregistries.js'),
|
||||
|
||||
@@ -266,6 +266,9 @@ async function initializeExpressSync() {
|
||||
router.get ('/api/v1/appstore/apps/:appstoreId', token, authorizeAdmin, routes.appstore.getApp);
|
||||
router.get ('/api/v1/appstore/apps/:appstoreId/versions/:versionId', token, authorizeAdmin, routes.appstore.getAppVersion);
|
||||
|
||||
// community app routes
|
||||
router.get ('/api/v1/community/app', token, authorizeAdmin, routes.community.getAppVersion);
|
||||
|
||||
// app routes
|
||||
router.post('/api/v1/apps/install', jsonOrMultipart, token, authorizeAdmin, routes.apps.install); // DEPRECATED from 8.1 on in favor of route below
|
||||
router.post('/api/v1/apps', jsonOrMultipart, token, authorizeAdmin, routes.apps.install);
|
||||
|
||||
Reference in New Issue
Block a user