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);