diff --git a/dashboard/src/components/CommunityAppDialog.vue b/dashboard/src/components/CommunityAppDialog.vue index 117d24691..3ac512812 100644 --- a/dashboard/src/components/CommunityAppDialog.vue +++ b/dashboard/src/components/CommunityAppDialog.vue @@ -35,8 +35,8 @@ async function onSubmit() { } const packageData = { - ...result, // { manifest, publishState, creationDate, ts } - versionsUrl: `${url}@${version || 'latest'}`, + ...result, // { manifest, publishState, creationDate, ts, versionsUrl } + versionsUrl: result.versionsUrl, iconUrl: result.manifest.iconUrl // compat with app store format }; diff --git a/src/community.js b/src/community.js index 99ae754f6..86836e030 100644 --- a/src/community.js +++ b/src/community.js @@ -8,15 +8,13 @@ import superagent from '@cloudron/superagent'; const debug = debugModule('box:community'); +const CLOUDRON_VERSIONS_FILE = 'CloudronVersions.json'; -async function getAppVersion(url, version) { +async function fetchVersionsRoot(url) { 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 (error) throw new BoxError(BoxError.NETWORK_ERROR, 'Network error downloading manifest: ' + error.message); 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}`); @@ -25,6 +23,13 @@ async function getAppVersion(url, version) { const versionsError = manifestFormat.parseVersions(versionsRoot); if (versionsError) throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid CloudronVersions.json: ${versionsError.message}`); + return versionsRoot; +} + +function getVersionData(versionsRoot, version) { + assert.strictEqual(typeof versionsRoot, 'object'); + assert.strictEqual(typeof version, 'string'); + const versions = versionsRoot.versions; const sortedVersions = Object.keys(versions).filter(v => versions[v].publishState !== 'revoked').sort(manifestFormat.packageVersionCompare); const versionData = version === 'latest' ? versions[sortedVersions.at(-1)] : versions[version]; @@ -36,6 +41,73 @@ async function getAppVersion(url, version) { return versionData; // { manifest, publishState, creationDate, ts } } +function _toRawUrl(parsedUrl) { + assert.strictEqual(typeof parsedUrl, 'object'); + + if (parsedUrl.hostname === 'raw.githubusercontent.com') return null; // no rewrite + + if (parsedUrl.hostname === 'github.com') { + const match = parsedUrl.pathname.match(/^\/([^/]+)\/([^/]+)\/blob\/(.+)$/); + if (match) { + const [, user, repo, rest] = match; + return `https://raw.githubusercontent.com/${user}/${repo}/${rest}`; + } + } + + // GitLab (gitlab.com or self-hosted with "gitlab" in hostname) + if (parsedUrl.hostname.includes('gitlab')) { + const match = parsedUrl.pathname.match(/^((?:\/[^/]+)+)\/-\/blob\/(.+)$/); + if (match) { + const [, repoBase, rest] = match; + return `https://${parsedUrl.hostname}${repoBase}/-/raw/${rest}`; + } + } + + // Gitea / Forgejo / Gogs + // Handles /user/repo/src/(branch|tag|commit)/ref/...path + const giteaMatch = parsedUrl.pathname.match(/^(\/[^/]+\/[^/]+)\/src\/(branch|tag|commit)\/(.+)$/); + if (giteaMatch) { + const [, repoBase, refType, rest] = giteaMatch; + return `https://${parsedUrl.hostname}${repoBase}/raw/${refType}/${rest}`; + } + + return null; +} + +async function resolveVersionsUrl(url, version = 'latest') { + assert.strictEqual(typeof url, 'string'); + assert.strictEqual(typeof version, 'string'); + + const parsedUrl = safe(() => new URL(url)); + if (!parsedUrl) throw new BoxError(BoxError.BAD_FIELD, 'versionsUrl is not a valid URL'); + if (parsedUrl.protocol !== 'https:') throw new BoxError(BoxError.BAD_FIELD, 'versionsUrl must use https'); + + const rawUrl = _toRawUrl(parsedUrl); + const candidates = []; + if (parsedUrl.pathname.endsWith(CLOUDRON_VERSIONS_FILE)) candidates.push(url); + if (rawUrl && rawUrl !== url) candidates.push(rawUrl); + + for (const candidate of candidates) { + const [error, versionsRoot] = await safe(fetchVersionsRoot(candidate)); + if (!error) { + const versionData = getVersionData(versionsRoot, version); + return { ...versionData, resolvedUrl: candidate, versionsUrl: `${candidate}@${version}` }; + } + } + + throw new BoxError(BoxError.NOT_FOUND, 'Could not resolve CloudronVersions.json from URL'); +} + +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 versionsRoot = await fetchVersionsRoot(url); + return getVersionData(versionsRoot, version); +} + async function downloadManifest(versionsUrl) { assert.strictEqual(typeof versionsUrl, 'string'); @@ -50,15 +122,7 @@ async function downloadManifest(versionsUrl) { debug(`downloading manifest from ${url} version ${version}`); - const [error, response] = await safe(superagent.get(url).timeout(60 * 1000).ok(() => true)); - if (error) throw new BoxError(BoxError.NETWORK_ERROR, 'Network error downloading manifest: ' + error.message); - 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}`); - - // if the content-type is incorrect, we will get a buffer - const versionsRoot = Buffer.isBuffer(response.body) ? safe.JSON.parse(response.body.toString('utf8')) : response.body; - const versionsError = manifestFormat.parseVersions(versionsRoot); - if (versionsError) throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid CloudronVersions.json: ${versionsError.message}`); + const versionsRoot = await fetchVersionsRoot(url); const versions = versionsRoot.versions; const sortedVersions = Object.keys(versions).filter(v => versions[v].publishState !== 'revoked').sort(manifestFormat.packageVersionCompare); @@ -116,6 +180,8 @@ async function downloadIcon(manifest) { } export default { + _toRawUrl, + resolveVersionsUrl, getAppVersion, downloadManifest, getAppUpdate, diff --git a/src/routes/community.js b/src/routes/community.js index 437922eb1..1cdaac288 100644 --- a/src/routes/community.js +++ b/src/routes/community.js @@ -9,10 +9,10 @@ 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)); + const [error, result] = await safe(community.resolveVersionsUrl(req.query.url, req.query.version)); if (error) return next(BoxError.toHttpError(error)); - next(new HttpSuccess(200, result)); // { manifest, publishState, creationDate, ts } + next(new HttpSuccess(200, result)); // { manifest, publishState, creationDate, ts, resolvedUrl, versionsUrl } } export default { diff --git a/src/routes/test/community-test.js b/src/routes/test/community-test.js new file mode 100644 index 000000000..896c9b794 --- /dev/null +++ b/src/routes/test/community-test.js @@ -0,0 +1,91 @@ +import { describe, it, before, after } from 'mocha'; +import assert from 'node:assert/strict'; +import common from './common.js'; +import nock from 'nock'; +import superagent from '@cloudron/superagent'; + +describe('Community API', function () { + const { setup, cleanup, serverUrl, owner } = common; + const baseManifest = { + id: 'io.cloudron.test', + author: 'The Presidents Of the United States Of America', + title: 'test title', + description: 'test description', + tagline: 'test rocks', + website: 'http://test.cloudron.io', + contactEmail: 'test@cloudron.io', + version: '0.1.0', + manifestVersion: 2, + dockerImage: 'cloudron/test:25.2.0', + healthCheckPath: '/', + httpPort: 7777, + tcpPorts: { + ECHO_SERVER_PORT: { + title: 'Echo Server Port', + description: 'Echo server', + containerPort: 7778 + } + }, + addons: { + oauth: {}, + redis: {}, + mysql: {}, + postgresql: {} + } + }; + + before(async function () { + await setup(); + if (!nock.isActive()) nock.restore(); + }); + + after(async function () { + await cleanup(); + }); + + it('returns canonical versionsUrl for community preview', async function () { + const validManifest = Object.assign({}, baseManifest, { + minBoxVersion: '9.1.0', + iconUrl: 'https://example.com/icon.png', + packagerName: 'Cloudron', + packagerUrl: 'https://cloudron.io', + tags: [ 'test' ], + changelog: '12345', + mediaLinks: [ 'https://example.com/shot.png' ] + }); + + const versionsFixture = { + stable: true, + versions: { + '0.1.0': { + manifest: validManifest, + publishState: 'published', + creationDate: '2026-01-01T00:00:00.000Z', + ts: Date.now() + } + } + }; + + const scope1 = nock('https://github.com') + .get('/cloudron/test-app/blob/main/CloudronVersions.json') + .reply(200, 'not json'); + + const scope2 = nock('https://raw.githubusercontent.com') + .get('/cloudron/test-app/main/CloudronVersions.json') + .reply(200, versionsFixture); + + const response = await superagent.get(`${serverUrl}/api/v1/community/app`) + .query({ + access_token: owner.token, + url: 'https://github.com/cloudron/test-app/blob/main/CloudronVersions.json', + version: 'latest' + }) + .ok(() => true); + + assert.equal(response.status, 200, response.body?.message || response.text); + assert.equal(response.body.versionsUrl, 'https://raw.githubusercontent.com/cloudron/test-app/main/CloudronVersions.json@latest'); + assert.equal(response.body.manifest.id, validManifest.id); + assert.ok(scope1.isDone()); + assert.ok(scope2.isDone()); + }); +}); diff --git a/src/test/community-test.js b/src/test/community-test.js new file mode 100644 index 000000000..6f3b3d356 --- /dev/null +++ b/src/test/community-test.js @@ -0,0 +1,144 @@ +import { describe, it, before, after, afterEach } from 'mocha'; +import assert from 'node:assert/strict'; +import BoxError from '../boxerror.js'; +import common from './common.js'; +import community from '../community.js'; +import nock from 'nock'; +import safe from 'safetydance'; + +const validManifest = Object.assign({}, common.manifest, { + minBoxVersion: '9.1.0', + iconUrl: 'https://example.com/icon.png', + packagerName: 'Cloudron', + packagerUrl: 'https://cloudron.io', + tags: [ 'test' ], + changelog: '12345', + mediaLinks: [ 'https://example.com/shot.png' ] +}); + +const versionsFixture = { + stable: true, + versions: { + '0.1.0': { + manifest: validManifest, + publishState: 'published', + creationDate: '2026-01-01T00:00:00.000Z', + ts: Date.now() + } + } +}; + +describe('Community', function () { + before(function () { + if (!nock.isActive()) nock.activate(); + }); + + after(function () { + nock.restore(); + }); + + afterEach(function () { + nock.cleanAll(); + }); + + describe('_toRawUrl', function () { + it('rewrites github blob root and nested paths', async function () { + assert.equal( + community._toRawUrl(new URL('https://github.com/cloudron/test-app/blob/main/CloudronVersions.json')), + 'https://raw.githubusercontent.com/cloudron/test-app/main/CloudronVersions.json' + ); + assert.equal( + community._toRawUrl(new URL('https://github.com/cloudron/test-app/blob/main/packaging/CloudronVersions.json')), + 'https://raw.githubusercontent.com/cloudron/test-app/main/packaging/CloudronVersions.json' + ); + }); + + it('rewrites gitlab blob root and nested paths', async function () { + assert.equal( + community._toRawUrl(new URL('https://gitlab.com/cloudron/test-app/-/blob/main/CloudronVersions.json')), + 'https://gitlab.com/cloudron/test-app/-/raw/main/CloudronVersions.json' + ); + assert.equal( + community._toRawUrl(new URL('https://my.gitlab.company/group/subgroup/test-app/-/blob/main/path/CloudronVersions.json')), + 'https://my.gitlab.company/group/subgroup/test-app/-/raw/main/path/CloudronVersions.json' + ); + }); + + it('rewrites gitea/forgejo/gogs src paths', async function () { + assert.equal( + community._toRawUrl(new URL('https://gitea.example.com/user/repo/src/branch/main/CloudronVersions.json')), + 'https://gitea.example.com/user/repo/raw/branch/main/CloudronVersions.json' + ); + assert.equal( + community._toRawUrl(new URL('https://codeberg.org/forgejo/forgejo/src/branch/forgejo/packaging/CloudronVersions.json')), + 'https://codeberg.org/forgejo/forgejo/raw/branch/forgejo/packaging/CloudronVersions.json' + ); + assert.equal( + community._toRawUrl(new URL('https://gogs.example.com/user/repo/src/tag/v1.0.0/CloudronVersions.json')), + 'https://gogs.example.com/user/repo/raw/tag/v1.0.0/CloudronVersions.json' + ); + }); + + it('returns null for non-rewrite URLs', async function () { + assert.equal( + community._toRawUrl(new URL('https://raw.githubusercontent.com/cloudron/test-app/main/CloudronVersions.json')), + null + ); + assert.equal( + community._toRawUrl(new URL('https://github.com/cloudron/test-app')), + null + ); + }); + }); + + it('resolveVersionsUrl returns direct URL and version data', async function () { + nock('https://example.com') + .get('/CloudronVersions.json') + .reply(200, versionsFixture); + + const result = await community.resolveVersionsUrl('https://example.com/CloudronVersions.json', '0.1.0'); + assert.equal(result.resolvedUrl, 'https://example.com/CloudronVersions.json'); + assert.equal(result.versionsUrl, 'https://example.com/CloudronVersions.json@0.1.0'); + assert.deepEqual(result.manifest, validManifest); + }); + + it('resolveVersionsUrl handles browser blob link to CloudronVersions.json', async function () { + nock('https://github.com') + .get('/cloudron/test-app/blob/main/CloudronVersions.json') + .reply(200, 'not json'); + + nock('https://raw.githubusercontent.com') + .get('/cloudron/test-app/main/CloudronVersions.json') + .reply(200, versionsFixture); + + const result = await community.resolveVersionsUrl('https://github.com/cloudron/test-app/blob/main/CloudronVersions.json', 'latest'); + + assert.equal(result.resolvedUrl, 'https://raw.githubusercontent.com/cloudron/test-app/main/CloudronVersions.json'); + assert.equal(result.versionsUrl, 'https://raw.githubusercontent.com/cloudron/test-app/main/CloudronVersions.json@latest'); + }); + + it('resolveVersionsUrl preserves hinted path from blob URL', async function () { + nock('https://raw.githubusercontent.com') + .get('/cloudron/test-app/main/packaging/CloudronVersions.json') + .reply(200, versionsFixture); + + const result = await community.resolveVersionsUrl('https://github.com/cloudron/test-app/blob/main/packaging/CloudronVersions.json', 'latest'); + assert.equal(result.resolvedUrl, 'https://raw.githubusercontent.com/cloudron/test-app/main/packaging/CloudronVersions.json'); + }); + + it('resolveVersionsUrl accepts already raw github URL', async function () { + nock('https://raw.githubusercontent.com') + .get('/cloudron/test-app/main/CloudronVersions.json') + .reply(200, versionsFixture); + + const result = await community.resolveVersionsUrl('https://raw.githubusercontent.com/cloudron/test-app/main/CloudronVersions.json', 'latest'); + + assert.equal(result.resolvedUrl, 'https://raw.githubusercontent.com/cloudron/test-app/main/CloudronVersions.json'); + }); + + it('resolveVersionsUrl returns not found when no candidate works', async function () { + const [error] = await safe(community.resolveVersionsUrl('https://github.com/cloudron/missing', 'latest')); + assert.equal(error.reason, BoxError.NOT_FOUND); + }); + +});