diff --git a/CHANGES b/CHANGES index 8bbee10e5..eafd03043 100644 --- a/CHANGES +++ b/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 diff --git a/dashboard/src/components/AppInstallDialog.vue b/dashboard/src/components/AppInstallDialog.vue index 19dc9330b..80ff0ad64 100644 --- a/dashboard/src/components/AppInstallDialog.vue +++ b/dashboard/src/components/AppInstallDialog.vue @@ -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(); diff --git a/dashboard/src/components/CommunityAppDialog.vue b/dashboard/src/components/CommunityAppDialog.vue new file mode 100644 index 000000000..deecc7228 --- /dev/null +++ b/dashboard/src/components/CommunityAppDialog.vue @@ -0,0 +1,75 @@ + + + diff --git a/dashboard/src/models/CommunityModel.js b/dashboard/src/models/CommunityModel.js new file mode 100644 index 000000000..a024d2726 --- /dev/null +++ b/dashboard/src/models/CommunityModel.js @@ -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, +}; diff --git a/dashboard/src/views/AppstoreView.vue b/dashboard/src/views/AppstoreView.vue index 34cd69611..4bd2c45dc 100644 --- a/dashboard/src/views/AppstoreView.vue +++ b/dashboard/src/views/AppstoreView.vue @@ -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(() => {
+
@@ -229,6 +241,7 @@ onDeactivated(() => { +
diff --git a/package-lock.json b/package-lock.json index 16eab23f2..4d093e5ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f4952f395..18592de72 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/community.js b/src/community.js new file mode 100644 index 000000000..c698a7202 --- /dev/null +++ b/src/community.js @@ -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 } + }; +} diff --git a/src/routes/community.js b/src/routes/community.js new file mode 100644 index 000000000..0314799ae --- /dev/null +++ b/src/routes/community.js @@ -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)); +} diff --git a/src/routes/index.js b/src/routes/index.js index 701b9150d..cc8189d51 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -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'), diff --git a/src/server.js b/src/server.js index af268658b..0e9270c40 100644 --- a/src/server.js +++ b/src/server.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);