mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-15 18:47:30 +00:00
feat: scope index_url uniqueness to hosted domains (#2644)
* feat: scope index_url uniqueness to hosted domains Allows duplicate non-hosted origins while enforcing hosted-origin uniqueness and joining new app creation into an existing owner-matching hosted app UUID. Restricts hosted index_url joins to unowned conflict apps, persists old-to-canonical app UID aliases in kvstore via sudo, and resolves aliases on read with parallel lookup to minimize latency. * fix: allow merge for owned origin bootstrap apps Restores hosted update/create merge when the conflicting app is the same-user auto-created origin bootstrap app while still blocking merges into normal same-owner apps to avoid multi-join behavior.
This commit is contained in:
@@ -18,9 +18,23 @@ import { AppIconService } from '../apps/AppIconService';
|
||||
import { AppInformationService } from '../apps/AppInformationService';
|
||||
import { OldAppNameService } from '../apps/OldAppNameService';
|
||||
import AppService from './AppService';
|
||||
import config from '../../config.js';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const getHostedIndexUrl = subdomain => {
|
||||
const hostedDomainCandidate = [
|
||||
config.static_hosting_domain_alt,
|
||||
config.static_hosting_domain,
|
||||
config.private_app_hosting_domain_alt,
|
||||
config.private_app_hosting_domain,
|
||||
].find(domainValue => typeof domainValue === 'string' && domainValue.trim());
|
||||
const hostedDomain = hostedDomainCandidate
|
||||
? hostedDomainCandidate.trim().toLowerCase().replace(/^\./, '').split(':')[0]
|
||||
: 'site.puter.localhost';
|
||||
return `https://${subdomain}.${hostedDomain}`;
|
||||
};
|
||||
|
||||
const ES_APP_ARGS = {
|
||||
entity: 'app',
|
||||
upstream: ESBuilder.create([
|
||||
@@ -736,6 +750,335 @@ describe('AppService Regression Prevention Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow duplicate non-hosted index_url', async () => {
|
||||
await testWithEachService(async ({ kernel, key }) => {
|
||||
const service = kernel.services.get(key);
|
||||
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
|
||||
|
||||
const first = await crudQ.create.call(service, {
|
||||
object: {
|
||||
name: 'non-hosted-duplicate-1',
|
||||
title: 'Non Hosted Duplicate 1',
|
||||
index_url: 'https://example.com/shared-origin',
|
||||
},
|
||||
});
|
||||
|
||||
const second = await crudQ.create.call(service, {
|
||||
object: {
|
||||
name: 'non-hosted-duplicate-2',
|
||||
title: 'Non Hosted Duplicate 2',
|
||||
index_url: 'https://example.com/shared-origin',
|
||||
},
|
||||
});
|
||||
|
||||
expect(first.uid).toBeDefined();
|
||||
expect(second.uid).toBeDefined();
|
||||
expect(second.uid).not.toBe(first.uid);
|
||||
});
|
||||
});
|
||||
|
||||
it('should join existing unowned hosted index_url app on create', async () => {
|
||||
await testWithEachService(async ({ kernel, key, user }) => {
|
||||
const service = kernel.services.get(key);
|
||||
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
|
||||
const db = kernel.services.get('database').get('write', 'test');
|
||||
const hostedIndexUrl = getHostedIndexUrl('joinable-site');
|
||||
const existingUid = 'app-11111111-1111-4111-8111-111111111111';
|
||||
|
||||
kernel.services.set('puter-site', {
|
||||
get_subdomain: async (subdomain) => {
|
||||
const rows = await db.read(
|
||||
'SELECT * FROM subdomains WHERE subdomain = ? LIMIT 1',
|
||||
[subdomain],
|
||||
);
|
||||
return rows[0] || null;
|
||||
},
|
||||
});
|
||||
|
||||
await db.write(
|
||||
'INSERT INTO subdomains (uuid, subdomain, user_id, root_dir_id) VALUES (?, ?, ?, ?)',
|
||||
['sd-11111111-1111-4111-8111-111111111111', 'joinable-site', user.id, 111],
|
||||
);
|
||||
await db.write(
|
||||
'INSERT INTO apps (uid, name, title, description, index_url, owner_user_id) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[existingUid, 'joinable-existing-app', 'Joinable Existing App', 'Created from origin', hostedIndexUrl, null],
|
||||
);
|
||||
|
||||
const joined = await crudQ.create.call(service, {
|
||||
object: {
|
||||
name: 'joinable-hosted-app',
|
||||
title: 'Joinable Hosted App',
|
||||
description: 'Claimed by owner',
|
||||
index_url: hostedIndexUrl,
|
||||
},
|
||||
});
|
||||
|
||||
expect(joined.uid).toBe(existingUid);
|
||||
|
||||
const joinedRows = await db.read(
|
||||
'SELECT uid, name, owner_user_id FROM apps WHERE index_url = ?',
|
||||
[hostedIndexUrl],
|
||||
);
|
||||
expect(joinedRows).toHaveLength(1);
|
||||
expect(joinedRows[0].uid).toBe(existingUid);
|
||||
expect(joinedRows[0].name).toBe('joinable-hosted-app');
|
||||
expect(joinedRows[0].owner_user_id).toBe(user.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('should join existing unowned hosted index_url app on update', async () => {
|
||||
await testWithEachService(async ({ kernel, key, user }) => {
|
||||
const service = kernel.services.get(key);
|
||||
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
|
||||
const db = kernel.services.get('database').get('write', 'test');
|
||||
const hostedIndexUrl = getHostedIndexUrl('joinable-update-site');
|
||||
const existingUid = 'app-33333333-3333-4333-8333-333333333333';
|
||||
|
||||
kernel.services.set('puter-site', {
|
||||
get_subdomain: async (subdomain) => {
|
||||
const rows = await db.read(
|
||||
'SELECT * FROM subdomains WHERE subdomain = ? LIMIT 1',
|
||||
[subdomain],
|
||||
);
|
||||
return rows[0] || null;
|
||||
},
|
||||
});
|
||||
|
||||
await db.write(
|
||||
'INSERT INTO subdomains (uuid, subdomain, user_id, root_dir_id) VALUES (?, ?, ?, ?)',
|
||||
['sd-33333333-3333-4333-8333-333333333333', 'joinable-update-site', user.id, 333],
|
||||
);
|
||||
await db.write(
|
||||
'INSERT INTO apps (uid, name, title, description, index_url, owner_user_id) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[existingUid, 'joinable-update-existing', 'Joinable Update Existing', 'Auto-created app', hostedIndexUrl, null],
|
||||
);
|
||||
|
||||
const appToUpdate = await crudQ.create.call(service, {
|
||||
object: {
|
||||
name: 'joinable-update-source',
|
||||
title: 'Joinable Update Source',
|
||||
description: 'Source app to be merged',
|
||||
index_url: 'https://example.com/update-source',
|
||||
},
|
||||
});
|
||||
|
||||
const joined = await crudQ.update.call(service, {
|
||||
object: {
|
||||
uid: appToUpdate.uid,
|
||||
name: 'joinable-update-merged',
|
||||
title: 'Joinable Update Merged',
|
||||
description: 'Merged by owner',
|
||||
index_url: hostedIndexUrl,
|
||||
},
|
||||
});
|
||||
|
||||
expect(joined.uid).toBe(existingUid);
|
||||
|
||||
const joinedRows = await db.read(
|
||||
'SELECT uid, name, title, owner_user_id FROM apps WHERE index_url = ?',
|
||||
[hostedIndexUrl],
|
||||
);
|
||||
expect(joinedRows).toHaveLength(1);
|
||||
expect(joinedRows[0].uid).toBe(existingUid);
|
||||
expect(joinedRows[0].name).toBe('joinable-update-existing');
|
||||
expect(joinedRows[0].title).toBe('Joinable Update Merged');
|
||||
expect(joinedRows[0].owner_user_id).toBe(user.id);
|
||||
|
||||
const sourceRows = await db.read(
|
||||
'SELECT uid FROM apps WHERE uid = ?',
|
||||
[appToUpdate.uid],
|
||||
);
|
||||
expect(sourceRows).toHaveLength(0);
|
||||
|
||||
const aliasedRead = await crudQ.read.call(service, {
|
||||
uid: appToUpdate.uid,
|
||||
});
|
||||
expect(aliasedRead.uid).toBe(existingUid);
|
||||
});
|
||||
});
|
||||
|
||||
it('should join on update when name matches source app name', async () => {
|
||||
await testWithEachService(async ({ kernel, key, user }) => {
|
||||
const service = kernel.services.get(key);
|
||||
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
|
||||
const db = kernel.services.get('database').get('write', 'test');
|
||||
const hostedIndexUrl = getHostedIndexUrl('joinable-update-self-name');
|
||||
const existingUid = 'app-44444444-4444-4444-8444-444444444444';
|
||||
|
||||
kernel.services.set('puter-site', {
|
||||
get_subdomain: async (subdomain) => {
|
||||
const rows = await db.read(
|
||||
'SELECT * FROM subdomains WHERE subdomain = ? LIMIT 1',
|
||||
[subdomain],
|
||||
);
|
||||
return rows[0] || null;
|
||||
},
|
||||
});
|
||||
|
||||
await db.write(
|
||||
'INSERT INTO subdomains (uuid, subdomain, user_id, root_dir_id) VALUES (?, ?, ?, ?)',
|
||||
['sd-44444444-4444-4444-8444-444444444444', 'joinable-update-self-name', user.id, 444],
|
||||
);
|
||||
await db.write(
|
||||
'INSERT INTO apps (uid, name, title, description, index_url, owner_user_id) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[existingUid, 'existing-target-name', 'Existing Target', 'Auto-created app', hostedIndexUrl, null],
|
||||
);
|
||||
|
||||
const source = await crudQ.create.call(service, {
|
||||
object: {
|
||||
name: 'staging-app-center',
|
||||
title: 'Source App',
|
||||
description: 'Source app before join',
|
||||
index_url: 'https://example.com/staging-source',
|
||||
},
|
||||
});
|
||||
|
||||
const joined = await crudQ.update.call(service, {
|
||||
object: {
|
||||
uid: source.uid,
|
||||
name: 'staging-app-center',
|
||||
title: 'Merged Title',
|
||||
index_url: hostedIndexUrl,
|
||||
},
|
||||
});
|
||||
|
||||
expect(joined.uid).toBe(existingUid);
|
||||
|
||||
const targetRows = await db.read(
|
||||
'SELECT uid, name, title FROM apps WHERE uid = ?',
|
||||
[existingUid],
|
||||
);
|
||||
expect(targetRows).toHaveLength(1);
|
||||
expect(targetRows[0].name).toBe('existing-target-name');
|
||||
expect(targetRows[0].title).toBe('Merged Title');
|
||||
|
||||
const sourceRows = await db.read(
|
||||
'SELECT uid FROM apps WHERE uid = ?',
|
||||
[source.uid],
|
||||
);
|
||||
expect(sourceRows).toHaveLength(0);
|
||||
|
||||
const aliasedRead = await crudQ.read.call(service, {
|
||||
uid: source.uid,
|
||||
});
|
||||
expect(aliasedRead.uid).toBe(existingUid);
|
||||
});
|
||||
});
|
||||
|
||||
it('should join owned bootstrap hosted app on update', async () => {
|
||||
await testWithEachService(async ({ kernel, key, user }) => {
|
||||
const service = kernel.services.get(key);
|
||||
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
|
||||
const db = kernel.services.get('database').get('write', 'test');
|
||||
const hostedIndexUrl = getHostedIndexUrl('joinable-owned-bootstrap');
|
||||
const existingUid = 'app-55555555-5555-4555-8555-555555555555';
|
||||
|
||||
kernel.services.set('puter-site', {
|
||||
get_subdomain: async (subdomain) => {
|
||||
const rows = await db.read(
|
||||
'SELECT * FROM subdomains WHERE subdomain = ? LIMIT 1',
|
||||
[subdomain],
|
||||
);
|
||||
return rows[0] || null;
|
||||
},
|
||||
});
|
||||
|
||||
await db.write(
|
||||
'INSERT INTO subdomains (uuid, subdomain, user_id, root_dir_id) VALUES (?, ?, ?, ?)',
|
||||
['sd-55555555-5555-4555-8555-555555555555', 'joinable-owned-bootstrap', user.id, 555],
|
||||
);
|
||||
await db.write(
|
||||
'INSERT INTO apps (uid, name, title, description, index_url, owner_user_id) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
existingUid,
|
||||
existingUid,
|
||||
existingUid,
|
||||
`App created from origin ${hostedIndexUrl}`,
|
||||
hostedIndexUrl,
|
||||
user.id,
|
||||
],
|
||||
);
|
||||
|
||||
const source = await crudQ.create.call(service, {
|
||||
object: {
|
||||
name: 'owned-bootstrap-source',
|
||||
title: 'Owned Bootstrap Source',
|
||||
description: 'Source app to be merged',
|
||||
index_url: 'https://example.com/owned-bootstrap-source',
|
||||
},
|
||||
});
|
||||
|
||||
const joined = await crudQ.update.call(service, {
|
||||
object: {
|
||||
uid: source.uid,
|
||||
title: 'Merged Bootstrap Title',
|
||||
index_url: hostedIndexUrl,
|
||||
},
|
||||
});
|
||||
|
||||
expect(joined.uid).toBe(existingUid);
|
||||
|
||||
const targetRows = await db.read(
|
||||
'SELECT uid, title, owner_user_id FROM apps WHERE uid = ?',
|
||||
[existingUid],
|
||||
);
|
||||
expect(targetRows).toHaveLength(1);
|
||||
expect(targetRows[0].title).toBe('Merged Bootstrap Title');
|
||||
expect(targetRows[0].owner_user_id).toBe(user.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject hosted duplicate index_url owned by another user', async () => {
|
||||
await testWithEachService(async ({ kernel, key, user }) => {
|
||||
const service = kernel.services.get(key);
|
||||
const crudQ = service.constructor.IMPLEMENTS['crud-q'];
|
||||
const db = kernel.services.get('database').get('write', 'test');
|
||||
const hostedIndexUrl = getHostedIndexUrl('foreign-owned');
|
||||
|
||||
kernel.services.set('puter-site', {
|
||||
get_subdomain: async (subdomain) => {
|
||||
const rows = await db.read(
|
||||
'SELECT * FROM subdomains WHERE subdomain = ? LIMIT 1',
|
||||
[subdomain],
|
||||
);
|
||||
return rows[0] || null;
|
||||
},
|
||||
});
|
||||
|
||||
await db.write(
|
||||
'INSERT INTO user (uuid, username, free_storage) VALUES (?, ?, ?)',
|
||||
['user-uuid-2', 'otheruser', 1024 * 1024 * 1024],
|
||||
);
|
||||
const otherUsers = await db.read('SELECT id FROM user WHERE uuid = ?', ['user-uuid-2']);
|
||||
const otherUserId = otherUsers[0].id;
|
||||
|
||||
await db.write(
|
||||
'INSERT INTO subdomains (uuid, subdomain, user_id, root_dir_id) VALUES (?, ?, ?, ?)',
|
||||
['sd-22222222-2222-4222-8222-222222222222', 'foreign-owned', user.id, 222],
|
||||
);
|
||||
await db.write(
|
||||
'INSERT INTO apps (uid, name, title, description, index_url, owner_user_id) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
['app-22222222-2222-4222-8222-222222222222', 'foreign-owned-existing', 'Foreign Owned Existing', 'Owned by another user', hostedIndexUrl, otherUserId],
|
||||
);
|
||||
|
||||
let errorThrown = false;
|
||||
try {
|
||||
await crudQ.create.call(service, {
|
||||
object: {
|
||||
name: 'foreign-owned-new',
|
||||
title: 'Foreign Owned New',
|
||||
index_url: hostedIndexUrl,
|
||||
},
|
||||
});
|
||||
} catch ( error ) {
|
||||
errorThrown = true;
|
||||
const code = error.fields?.code || error.code;
|
||||
expect(code).toBe('app_index_url_already_in_use');
|
||||
}
|
||||
expect(errorThrown).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should dedupe name with dedupe_name option', async () => {
|
||||
await testWithEachService(async ({ kernel, key }) => {
|
||||
const service = kernel.services.get(key);
|
||||
|
||||
@@ -28,6 +28,7 @@ const APP_ICON_ENDPOINT_PATH_REGEX = /^\/app-icon\/([^/?#]+)(?:\/(\d+))?\/?$/;
|
||||
const LEGACY_APP_ICON_FILE_PATH_REGEX = /^\/(app-[^/?#]+?)(?:-(\d+))?\.png$/;
|
||||
const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
|
||||
const RAW_BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||
const APP_UID_ALIAS_KEY_PREFIX = 'app:canonicalUidAlias';
|
||||
const indexUrlUniquenessExemptionCandidates = [
|
||||
'https://dev-center.puter.com/coming-soon',
|
||||
];
|
||||
@@ -468,11 +469,13 @@ export default class AppService extends BaseService {
|
||||
// Build WHERE clause based on identifier type
|
||||
let whereClause;
|
||||
let whereValues;
|
||||
let canonicalUidAliasPromise = null;
|
||||
|
||||
if ( uid !== undefined ) {
|
||||
// Simple uid lookup
|
||||
whereClause = 'apps.uid = ?';
|
||||
whereValues = [uid];
|
||||
canonicalUidAliasPromise = this.#readCanonicalAppUidAlias(uid);
|
||||
} else if ( id !== null && typeof id === 'object' && !Array.isArray(id) ) {
|
||||
// Complex id lookup (e.g., { name: 'editor' })
|
||||
const { clause, values } = this.#build_complex_id_where(id);
|
||||
@@ -493,7 +496,18 @@ export default class AppService extends BaseService {
|
||||
`WHERE ${whereClause} ` +
|
||||
'LIMIT 1';
|
||||
|
||||
const rows = await db.read(stmt, whereValues);
|
||||
let rows = await db.read(stmt, whereValues);
|
||||
|
||||
if ( rows.length === 0 && canonicalUidAliasPromise ) {
|
||||
const canonicalUid = await canonicalUidAliasPromise;
|
||||
if (
|
||||
typeof canonicalUid === 'string'
|
||||
&& canonicalUid
|
||||
&& canonicalUid !== uid
|
||||
) {
|
||||
rows = await db.read(stmt, [canonicalUid]);
|
||||
}
|
||||
}
|
||||
|
||||
if ( rows.length === 0 ) {
|
||||
throw APIError.create('entity_not_found', null, {
|
||||
@@ -736,6 +750,14 @@ export default class AppService extends BaseService {
|
||||
|
||||
// Ensure puter.site subdomain is owned by user (if index_url uses it)
|
||||
await this.#ensure_puter_site_subdomain_is_owned(object.index_url, user);
|
||||
const joinedApp = await this.#maybeJoinOwnedHostedIndexUrlAppOnCreate({
|
||||
object,
|
||||
options,
|
||||
user,
|
||||
});
|
||||
if ( joinedApp ) {
|
||||
return joinedApp;
|
||||
}
|
||||
await this.#ensureIndexUrlNotAlreadyInUse({
|
||||
indexUrl: object.index_url,
|
||||
});
|
||||
@@ -1005,6 +1027,15 @@ export default class AppService extends BaseService {
|
||||
// Ensure puter.site subdomain is owned by user (if index_url changed)
|
||||
if ( object.index_url && object.index_url !== old_app.index_url ) {
|
||||
await this.#ensure_puter_site_subdomain_is_owned(object.index_url, user);
|
||||
const joinedApp = await this.#maybeJoinOwnedHostedIndexUrlAppOnCreate({
|
||||
object,
|
||||
options,
|
||||
user,
|
||||
excludeAppId: old_app.id,
|
||||
});
|
||||
if ( joinedApp ) {
|
||||
return joinedApp;
|
||||
}
|
||||
await this.#ensureIndexUrlNotAlreadyInUse({
|
||||
indexUrl: object.index_url,
|
||||
excludeAppId: old_app.id,
|
||||
@@ -1156,6 +1187,10 @@ export default class AppService extends BaseService {
|
||||
return null;
|
||||
}
|
||||
|
||||
#isPuterHostedIndexUrl (indexUrl) {
|
||||
return !!this.#extractPuterHostedSubdomain(indexUrl);
|
||||
}
|
||||
|
||||
#buildEquivalentIndexUrlCandidates (indexUrl) {
|
||||
if ( typeof indexUrl !== 'string' || !indexUrl.trim() ) {
|
||||
return [];
|
||||
@@ -1185,14 +1220,18 @@ export default class AppService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
async #ensureIndexUrlNotAlreadyInUse ({ indexUrl, excludeAppId } = {}) {
|
||||
async #findIndexUrlConflictRow ({ indexUrl, excludeAppId } = {}) {
|
||||
if ( ! this.#isPuterHostedIndexUrl(indexUrl) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const indexUrlCandidates = this.#buildEquivalentIndexUrlCandidates(indexUrl);
|
||||
if ( indexUrlCandidates.length === 0 ) return;
|
||||
if ( hasIndexUrlUniquenessExemption(indexUrlCandidates) ) return;
|
||||
if ( indexUrlCandidates.length === 0 ) return null;
|
||||
if ( hasIndexUrlUniquenessExemption(indexUrlCandidates) ) return null;
|
||||
|
||||
const placeholders = indexUrlCandidates.map(() => '?').join(', ');
|
||||
const parameters = [...indexUrlCandidates];
|
||||
let query = `SELECT id, uid, index_url FROM apps WHERE index_url IN (${placeholders})`;
|
||||
let query = `SELECT id, uid, owner_user_id, index_url FROM apps WHERE index_url IN (${placeholders})`;
|
||||
|
||||
if ( Number.isInteger(excludeAppId) && excludeAppId > 0 ) {
|
||||
query += ' AND id != ?';
|
||||
@@ -1208,6 +1247,11 @@ export default class AppService extends BaseService {
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return conflictRow || null;
|
||||
}
|
||||
|
||||
async #ensureIndexUrlNotAlreadyInUse ({ indexUrl, excludeAppId } = {}) {
|
||||
const conflictRow = await this.#findIndexUrlConflictRow({ indexUrl, excludeAppId });
|
||||
if ( conflictRow ) {
|
||||
throw APIError.create('app_index_url_already_in_use', null, {
|
||||
index_url: indexUrl,
|
||||
@@ -1216,6 +1260,168 @@ export default class AppService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
async #claimAppOwnershipByIdForUser ({ appId, userId }) {
|
||||
if ( !Number.isInteger(appId) || appId <= 0 ) return;
|
||||
if ( !Number.isInteger(userId) || userId <= 0 ) return;
|
||||
|
||||
await this.db_write.write(
|
||||
'UPDATE apps SET owner_user_id = ? WHERE id = ? AND owner_user_id IS NULL',
|
||||
[userId, appId],
|
||||
);
|
||||
}
|
||||
|
||||
#buildCanonicalAppUidAliasKey (oldAppUid) {
|
||||
return `${APP_UID_ALIAS_KEY_PREFIX}:${oldAppUid}`;
|
||||
}
|
||||
|
||||
async #readCanonicalAppUidAlias (oldAppUid) {
|
||||
if ( typeof oldAppUid !== 'string' || !oldAppUid ) return null;
|
||||
|
||||
const kvStore = this.services.get('puter-kvstore');
|
||||
const suService = this.services.get('su');
|
||||
if ( !kvStore || typeof kvStore.get !== 'function' ) return null;
|
||||
if ( !suService || typeof suService.sudo !== 'function' ) return null;
|
||||
|
||||
const key = this.#buildCanonicalAppUidAliasKey(oldAppUid);
|
||||
try {
|
||||
const canonicalAppUid = await suService.sudo(() => kvStore.get({ key }));
|
||||
if ( typeof canonicalAppUid === 'string' && canonicalAppUid ) {
|
||||
return canonicalAppUid;
|
||||
}
|
||||
} catch {
|
||||
// Alias reads are best-effort.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async #writeCanonicalAppUidAlias ({ oldAppUid, canonicalAppUid }) {
|
||||
if ( typeof oldAppUid !== 'string' || !oldAppUid ) return;
|
||||
if ( typeof canonicalAppUid !== 'string' || !canonicalAppUid ) return;
|
||||
if ( oldAppUid === canonicalAppUid ) return;
|
||||
|
||||
const kvStore = this.services.get('puter-kvstore');
|
||||
const suService = this.services.get('su');
|
||||
if ( !kvStore || typeof kvStore.set !== 'function' ) return;
|
||||
if ( !suService || typeof suService.sudo !== 'function' ) return;
|
||||
|
||||
const key = this.#buildCanonicalAppUidAliasKey(oldAppUid);
|
||||
try {
|
||||
await suService.sudo(() => kvStore.set({
|
||||
key,
|
||||
value: canonicalAppUid,
|
||||
}));
|
||||
} catch {
|
||||
// Alias writes are best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
async #maybeJoinOwnedHostedIndexUrlAppOnCreate ({
|
||||
object,
|
||||
options,
|
||||
user,
|
||||
excludeAppId,
|
||||
} = {}) {
|
||||
const indexUrl = object?.index_url;
|
||||
const sourceAppUid = object?.uid;
|
||||
if ( ! this.#isPuterHostedIndexUrl(indexUrl) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const conflictRow = await this.#findIndexUrlConflictRow({
|
||||
indexUrl,
|
||||
excludeAppId,
|
||||
});
|
||||
if ( ! conflictRow ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const conflictOwnerUserId = Number(conflictRow.owner_user_id);
|
||||
if (
|
||||
Number.isInteger(conflictOwnerUserId)
|
||||
&& conflictOwnerUserId > 0
|
||||
&& conflictOwnerUserId !== user.id
|
||||
) {
|
||||
throw APIError.create('app_index_url_already_in_use', null, {
|
||||
index_url: indexUrl,
|
||||
app_uid: conflictRow.uid,
|
||||
});
|
||||
}
|
||||
|
||||
if ( !Number.isInteger(conflictOwnerUserId) || conflictOwnerUserId <= 0 ) {
|
||||
await this.#claimAppOwnershipByIdForUser({
|
||||
appId: conflictRow.id,
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
const appToJoin = await this.#read({
|
||||
uid: conflictRow.uid,
|
||||
backend_only_options: {
|
||||
no_filter_owner: true,
|
||||
},
|
||||
});
|
||||
if ( !appToJoin || appToJoin.uid !== conflictRow.uid ) {
|
||||
throw APIError.create('app_index_url_already_in_use', null, {
|
||||
index_url: indexUrl,
|
||||
app_uid: conflictRow.uid,
|
||||
});
|
||||
}
|
||||
const appToJoinOwnerId = Number(appToJoin.owner?.id);
|
||||
if ( !Number.isInteger(appToJoinOwnerId) || appToJoinOwnerId !== user.id ) {
|
||||
throw APIError.create('app_index_url_already_in_use', null, {
|
||||
index_url: indexUrl,
|
||||
app_uid: conflictRow.uid,
|
||||
});
|
||||
}
|
||||
if (
|
||||
Number.isInteger(conflictOwnerUserId)
|
||||
&& conflictOwnerUserId === user.id
|
||||
&& !this.#isOriginBootstrapApp(appToJoin)
|
||||
) {
|
||||
// Prevent merging arbitrary same-owner apps; only allow the
|
||||
// auto-created origin bootstrap app to be absorbed.
|
||||
throw APIError.create('app_index_url_already_in_use', null, {
|
||||
index_url: indexUrl,
|
||||
app_uid: conflictRow.uid,
|
||||
});
|
||||
}
|
||||
|
||||
const joinedObject = {
|
||||
...object,
|
||||
uid: appToJoin.uid,
|
||||
};
|
||||
if ( object?.uid && joinedObject.name !== undefined ) {
|
||||
delete joinedObject.name;
|
||||
}
|
||||
|
||||
const joinedApp = await this.#update({
|
||||
object: joinedObject,
|
||||
options,
|
||||
});
|
||||
|
||||
if ( sourceAppUid && sourceAppUid !== appToJoin.uid ) {
|
||||
await this.#writeCanonicalAppUidAlias({
|
||||
oldAppUid: sourceAppUid,
|
||||
canonicalAppUid: appToJoin.uid,
|
||||
});
|
||||
const svc_appInformation = this.services.get('app-information');
|
||||
if ( svc_appInformation?.delete_app ) {
|
||||
await svc_appInformation.delete_app(sourceAppUid);
|
||||
}
|
||||
}
|
||||
|
||||
return joinedApp;
|
||||
}
|
||||
|
||||
#isOriginBootstrapApp (app) {
|
||||
if ( !app || typeof app !== 'object' ) return false;
|
||||
if ( typeof app.uid !== 'string' || !app.uid ) return false;
|
||||
if ( app.name !== app.uid ) return false;
|
||||
if ( app.title !== app.uid ) return false;
|
||||
if ( typeof app.description !== 'string' ) return false;
|
||||
return app.description.startsWith('App created from origin ');
|
||||
}
|
||||
|
||||
async #handle_name_conflict (object, old_app, options) {
|
||||
const new_name = object.name;
|
||||
const old_name = old_app.name;
|
||||
|
||||
@@ -62,6 +62,9 @@ describe('AppService', () => {
|
||||
let mockPermissionService;
|
||||
let mockPuterSiteService;
|
||||
let mockOldAppNameService;
|
||||
let mockAppInformationService;
|
||||
let mockKvStoreService;
|
||||
let mockSuService;
|
||||
|
||||
// Helper to create a mock database row
|
||||
const createMockAppRow = (overrides = {}) => ({
|
||||
@@ -153,6 +156,23 @@ describe('AppService', () => {
|
||||
remove_name: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
// Mock app-information service
|
||||
mockAppInformationService = {
|
||||
delete_app: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
mockKvStoreService = {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
set: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
mockSuService = {
|
||||
sudo: vi.fn(async (actorOrCallback, maybeCallback) => {
|
||||
const callback = maybeCallback || actorOrCallback;
|
||||
return await callback();
|
||||
}),
|
||||
};
|
||||
|
||||
// Mock services
|
||||
mockServices = {
|
||||
get: vi.fn().mockImplementation((serviceName) => {
|
||||
@@ -168,6 +188,9 @@ describe('AppService', () => {
|
||||
if ( serviceName === 'permission' ) return mockPermissionService;
|
||||
if ( serviceName === 'puter-site' ) return mockPuterSiteService;
|
||||
if ( serviceName === 'old-app-name' ) return mockOldAppNameService;
|
||||
if ( serviceName === 'app-information' ) return mockAppInformationService;
|
||||
if ( serviceName === 'puter-kvstore' ) return mockKvStoreService;
|
||||
if ( serviceName === 'su' ) return mockSuService;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
@@ -236,6 +259,30 @@ describe('AppService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve app by canonical uid alias when old uid is missing', async () => {
|
||||
const canonicalRow = createMockAppRow({
|
||||
uid: 'app-canonical-uid-123',
|
||||
});
|
||||
mockDb.read
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([canonicalRow]);
|
||||
mockKvStoreService.get.mockResolvedValue('app-canonical-uid-123');
|
||||
|
||||
const crudQ = AppService.IMPLEMENTS['crud-q'];
|
||||
const result = await crudQ.read.call(appService, { uid: 'app-old-uid-123' });
|
||||
|
||||
expect(result.uid).toBe('app-canonical-uid-123');
|
||||
expect(mockSuService.sudo).toHaveBeenCalled();
|
||||
expect(mockKvStoreService.get).toHaveBeenCalledWith({
|
||||
key: 'app:canonicalUidAlias:app-old-uid-123',
|
||||
});
|
||||
expect(mockDb.read).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringContaining('WHERE apps.uid = ?'),
|
||||
['app-canonical-uid-123'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error when neither uid nor id is provided', async () => {
|
||||
const crudQ = AppService.IMPLEMENTS['crud-q'];
|
||||
|
||||
@@ -724,7 +771,7 @@ describe('AppService', () => {
|
||||
|
||||
// Mock the read after insert
|
||||
mockDb.read.mockImplementation(async (query) => {
|
||||
if ( typeof query === 'string' && query.includes('SELECT id, uid, index_url FROM apps WHERE index_url IN') ) {
|
||||
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
|
||||
return [];
|
||||
}
|
||||
return [createMockAppRow({
|
||||
@@ -892,13 +939,14 @@ describe('AppService', () => {
|
||||
})).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should throw when equivalent index_url is already in use on create', async () => {
|
||||
it('should allow equivalent index_url already in use on create for non-hosted origins', async () => {
|
||||
setupContextForWrite(createMockUserActor(1));
|
||||
mockDb.read.mockImplementation(async (query) => {
|
||||
if ( typeof query === 'string' && query.includes('SELECT id, uid, index_url FROM apps WHERE index_url IN') ) {
|
||||
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
|
||||
return [{
|
||||
id: 999,
|
||||
uid: 'app-existing-uid',
|
||||
owner_user_id: 1,
|
||||
index_url: 'https://example.com/',
|
||||
}];
|
||||
}
|
||||
@@ -913,16 +961,17 @@ describe('AppService', () => {
|
||||
title: 'New App',
|
||||
index_url: 'https://example.com/index.html',
|
||||
},
|
||||
})).rejects.toThrow();
|
||||
})).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow duplicate dev-center placeholder index_url on create', async () => {
|
||||
setupContextForWrite(createMockUserActor(1));
|
||||
mockDb.read.mockImplementation(async (query) => {
|
||||
if ( typeof query === 'string' && query.includes('SELECT id, uid, index_url FROM apps WHERE index_url IN') ) {
|
||||
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
|
||||
return [{
|
||||
id: 999,
|
||||
uid: 'app-existing-placeholder',
|
||||
owner_user_id: 1,
|
||||
index_url: 'https://dev-center.puter.com/coming-soon.html',
|
||||
}];
|
||||
}
|
||||
@@ -940,6 +989,75 @@ describe('AppService', () => {
|
||||
})).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('should join existing hosted app when index_url is owned and already used', async () => {
|
||||
setupContextForWrite(createMockUserActor(1));
|
||||
mockPuterSiteService.get_subdomain.mockResolvedValue({ user_id: 1 });
|
||||
mockDb.read.mockImplementation(async (query) => {
|
||||
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
|
||||
return [{
|
||||
id: 999,
|
||||
uid: 'app-existing-hosted',
|
||||
owner_user_id: null,
|
||||
index_url: 'https://mysite.puter.site',
|
||||
}];
|
||||
}
|
||||
return [createMockAppRow({
|
||||
id: 999,
|
||||
uid: 'app-existing-hosted',
|
||||
name: 'existing-hosted-app',
|
||||
title: 'Existing Hosted App',
|
||||
index_url: 'https://mysite.puter.site',
|
||||
owner_user_id: 1,
|
||||
})];
|
||||
});
|
||||
|
||||
const crudQ = AppService.IMPLEMENTS['crud-q'];
|
||||
const joined = await crudQ.create.call(appService, {
|
||||
object: {
|
||||
name: 'joined-hosted-app',
|
||||
title: 'Joined Hosted App',
|
||||
index_url: 'https://mysite.puter.site',
|
||||
},
|
||||
});
|
||||
|
||||
expect(joined.uid).toBe('app-existing-hosted');
|
||||
expect(mockDbWrite.write).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO apps'),
|
||||
expect.any(Array),
|
||||
);
|
||||
expect(mockKvStoreService.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw when hosted index_url is already in use by another owner on create', async () => {
|
||||
setupContextForWrite(createMockUserActor(1));
|
||||
mockPuterSiteService.get_subdomain.mockResolvedValue({ user_id: 1 });
|
||||
mockDb.read.mockImplementation(async (query) => {
|
||||
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
|
||||
return [{
|
||||
id: 999,
|
||||
uid: 'app-existing-hosted',
|
||||
owner_user_id: 2,
|
||||
index_url: 'https://mysite.puter.site',
|
||||
}];
|
||||
}
|
||||
return [createMockAppRow()];
|
||||
});
|
||||
|
||||
const crudQ = AppService.IMPLEMENTS['crud-q'];
|
||||
|
||||
await expect(crudQ.create.call(appService, {
|
||||
object: {
|
||||
name: 'new-app',
|
||||
title: 'New App',
|
||||
index_url: 'https://mysite.puter.site',
|
||||
},
|
||||
})).rejects.toMatchObject({
|
||||
fields: {
|
||||
code: 'app_index_url_already_in_use',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should set app_owner when actor is AppUnderUserActorType', async () => {
|
||||
setupContextForWrite(createMockAppUnderUserActor(1, 100));
|
||||
mockDb.read.mockResolvedValue([createMockAppRow()]);
|
||||
@@ -1190,7 +1308,7 @@ describe('AppService', () => {
|
||||
it('should call validate_url for index_url', async () => {
|
||||
setupContextForWrite(createMockUserActor(1));
|
||||
mockDb.read.mockImplementation(async (query) => {
|
||||
if ( typeof query === 'string' && query.includes('SELECT id, uid, index_url FROM apps WHERE index_url IN') ) {
|
||||
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
|
||||
return [];
|
||||
}
|
||||
return [createMockAppRow()];
|
||||
@@ -1670,13 +1788,14 @@ describe('AppService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when equivalent index_url is already in use on update', async () => {
|
||||
it('should allow equivalent index_url already in use on update for non-hosted origins', async () => {
|
||||
setupContextForWrite(createMockUserActor(1));
|
||||
mockDb.read.mockImplementation(async (query) => {
|
||||
if ( typeof query === 'string' && query.includes('SELECT id, uid, index_url FROM apps WHERE index_url IN') ) {
|
||||
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
|
||||
return [{
|
||||
id: 777,
|
||||
uid: 'app-conflict-uid',
|
||||
owner_user_id: 2,
|
||||
index_url: 'https://updated.com/',
|
||||
}];
|
||||
}
|
||||
@@ -1689,16 +1808,17 @@ describe('AppService', () => {
|
||||
uid: 'app-uid-123',
|
||||
index_url: 'https://updated.com/index.html',
|
||||
},
|
||||
})).rejects.toThrow();
|
||||
})).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow duplicate dev-center placeholder index_url on update', async () => {
|
||||
setupContextForWrite(createMockUserActor(1));
|
||||
mockDb.read.mockImplementation(async (query) => {
|
||||
if ( typeof query === 'string' && query.includes('SELECT id, uid, index_url FROM apps WHERE index_url IN') ) {
|
||||
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
|
||||
return [{
|
||||
id: 777,
|
||||
uid: 'app-existing-placeholder',
|
||||
owner_user_id: 1,
|
||||
index_url: 'https://dev-center.puter.com/coming-soon.html',
|
||||
}];
|
||||
}
|
||||
@@ -1714,6 +1834,115 @@ describe('AppService', () => {
|
||||
})).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('should join existing unowned hosted app when index_url is already in use on update', async () => {
|
||||
setupContextForWrite(createMockUserActor(1));
|
||||
mockPuterSiteService.get_subdomain.mockResolvedValue({ user_id: 1 });
|
||||
mockDb.read.mockImplementation(async (query, params) => {
|
||||
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
|
||||
return [{
|
||||
id: 777,
|
||||
uid: 'app-conflict-uid',
|
||||
owner_user_id: null,
|
||||
index_url: 'https://mysite.puter.site/',
|
||||
}];
|
||||
}
|
||||
if ( Array.isArray(params) && params[0] === 'app-conflict-uid' ) {
|
||||
return [createMockAppRow({
|
||||
id: 777,
|
||||
uid: 'app-conflict-uid',
|
||||
name: 'existing-hosted-app',
|
||||
title: 'Existing Hosted App',
|
||||
index_url: 'https://mysite.puter.site/',
|
||||
owner_user_id: 1,
|
||||
})];
|
||||
}
|
||||
return [createMockAppRow({
|
||||
id: 1,
|
||||
uid: 'app-uid-123',
|
||||
name: 'updating-app',
|
||||
title: 'Updating App',
|
||||
index_url: 'https://other.puter.site',
|
||||
owner_user_id: 1,
|
||||
})];
|
||||
});
|
||||
|
||||
const crudQ = AppService.IMPLEMENTS['crud-q'];
|
||||
const result = await crudQ.update.call(appService, {
|
||||
object: {
|
||||
uid: 'app-uid-123',
|
||||
title: 'Joined Update Title',
|
||||
index_url: 'https://mysite.puter.site/index.html',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.uid).toBe('app-conflict-uid');
|
||||
expect(mockDbWrite.write).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE apps SET'),
|
||||
expect.arrayContaining(['Joined Update Title', 777]),
|
||||
);
|
||||
expect(mockAppInformationService.delete_app).toHaveBeenCalledWith('app-uid-123');
|
||||
expect(mockKvStoreService.set).toHaveBeenCalledWith({
|
||||
key: 'app:canonicalUidAlias:app-uid-123',
|
||||
value: 'app-conflict-uid',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when owned hosted index_url is already in use on update', async () => {
|
||||
setupContextForWrite(createMockUserActor(1));
|
||||
mockPuterSiteService.get_subdomain.mockResolvedValue({ user_id: 1 });
|
||||
mockDb.read.mockImplementation(async (query) => {
|
||||
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
|
||||
return [{
|
||||
id: 777,
|
||||
uid: 'app-conflict-uid',
|
||||
owner_user_id: 1,
|
||||
index_url: 'https://mysite.puter.site/',
|
||||
}];
|
||||
}
|
||||
return [createMockAppRow()];
|
||||
});
|
||||
|
||||
const crudQ = AppService.IMPLEMENTS['crud-q'];
|
||||
await expect(crudQ.update.call(appService, {
|
||||
object: {
|
||||
uid: 'app-uid-123',
|
||||
index_url: 'https://mysite.puter.site/index.html',
|
||||
},
|
||||
})).rejects.toMatchObject({
|
||||
fields: {
|
||||
code: 'app_index_url_already_in_use',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when equivalent hosted index_url is already in use on update', async () => {
|
||||
setupContextForWrite(createMockUserActor(1));
|
||||
mockPuterSiteService.get_subdomain.mockResolvedValue({ user_id: 1 });
|
||||
mockDb.read.mockImplementation(async (query) => {
|
||||
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
|
||||
return [{
|
||||
id: 777,
|
||||
uid: 'app-conflict-uid',
|
||||
owner_user_id: 2,
|
||||
index_url: 'https://mysite.puter.site/',
|
||||
}];
|
||||
}
|
||||
return [createMockAppRow()];
|
||||
});
|
||||
|
||||
const crudQ = AppService.IMPLEMENTS['crud-q'];
|
||||
await expect(crudQ.update.call(appService, {
|
||||
object: {
|
||||
uid: 'app-uid-123',
|
||||
index_url: 'https://mysite.puter.site/index.html',
|
||||
},
|
||||
})).rejects.toMatchObject({
|
||||
fields: {
|
||||
code: 'app_index_url_already_in_use',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw forbidden when app actor does not own the entity (AppLimitedES behavior)', async () => {
|
||||
// App actor trying to update an app it didn't create
|
||||
setupContextForWrite(createMockAppUnderUserActor(1, 999));
|
||||
@@ -1811,8 +2040,6 @@ describe('AppService', () => {
|
||||
});
|
||||
|
||||
describe('#delete', () => {
|
||||
let mockAppInformationService;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock app-information service
|
||||
mockAppInformationService = {
|
||||
@@ -1834,6 +2061,8 @@ describe('AppService', () => {
|
||||
if ( serviceName === 'puter-site' ) return mockPuterSiteService;
|
||||
if ( serviceName === 'old-app-name' ) return mockOldAppNameService;
|
||||
if ( serviceName === 'app-information' ) return mockAppInformationService;
|
||||
if ( serviceName === 'puter-kvstore' ) return mockKvStoreService;
|
||||
if ( serviceName === 'su' ) return mockSuService;
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
@@ -27,8 +27,10 @@ const { Context } = require('../../util/context');
|
||||
const { origin_from_url } = require('../../util/urlutil');
|
||||
const { Eq, Like, Or, And } = require('../query/query');
|
||||
const { BaseES } = require('./BaseES');
|
||||
const { Entity } = require('./Entity');
|
||||
|
||||
const uuidv4 = require('uuid').v4;
|
||||
const APP_UID_ALIAS_KEY_PREFIX = 'app:canonicalUidAlias';
|
||||
const indexUrlUniquenessExemptionCandidates = [
|
||||
'https://dev-center.puter.com/coming-soon',
|
||||
];
|
||||
@@ -40,6 +42,54 @@ const hasIndexUrlUniquenessExemption = (candidates) => {
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const normalizeConfiguredHostedDomain = (domainValue) => {
|
||||
if ( typeof domainValue !== 'string' ) return null;
|
||||
const normalizedDomainValue = domainValue.trim().toLowerCase().replace(/^\./, '');
|
||||
if ( ! normalizedDomainValue ) return null;
|
||||
return normalizedDomainValue.split(':')[0] || null;
|
||||
};
|
||||
|
||||
const getConfiguredHostedDomains = () => {
|
||||
const hostedDomains = new Set();
|
||||
for ( const configuredDomain of [
|
||||
config.static_hosting_domain,
|
||||
config.static_hosting_domain_alt,
|
||||
config.private_app_hosting_domain,
|
||||
config.private_app_hosting_domain_alt,
|
||||
] ) {
|
||||
const normalizedDomain = normalizeConfiguredHostedDomain(configuredDomain);
|
||||
if ( normalizedDomain ) {
|
||||
hostedDomains.add(normalizedDomain);
|
||||
}
|
||||
}
|
||||
return [...hostedDomains];
|
||||
};
|
||||
|
||||
const extractPuterHostedSubdomainFromIndexUrl = (indexUrl) => {
|
||||
if ( typeof indexUrl !== 'string' || !indexUrl ) return null;
|
||||
|
||||
let hostname;
|
||||
try {
|
||||
hostname = (new URL(indexUrl)).hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostedDomains = getConfiguredHostedDomains()
|
||||
.sort((domainA, domainB) => domainB.length - domainA.length);
|
||||
|
||||
for ( const hostedDomain of hostedDomains ) {
|
||||
const suffix = `.${hostedDomain}`;
|
||||
if ( hostname.endsWith(suffix) ) {
|
||||
const subdomain = hostname.slice(0, hostname.length - suffix.length);
|
||||
return subdomain || null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
let privateLaunchAccessModulePromise;
|
||||
const getPrivateLaunchAccessModule = async () => {
|
||||
if ( ! privateLaunchAccessModulePromise ) {
|
||||
@@ -80,6 +130,25 @@ class AppES extends BaseES {
|
||||
await svc_appInformation.delete_app(uid);
|
||||
},
|
||||
|
||||
async read (uid) {
|
||||
if ( typeof uid !== 'string' || !uid ) {
|
||||
return await this.upstream.read(uid);
|
||||
}
|
||||
|
||||
const canonicalUidAliasPromise = this.read_canonical_app_uid_alias_(uid);
|
||||
const entity = await this.upstream.read(uid);
|
||||
if ( entity ) {
|
||||
return entity;
|
||||
}
|
||||
|
||||
const canonicalUid = await canonicalUidAliasPromise;
|
||||
if ( !canonicalUid || canonicalUid === uid ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await this.upstream.read(canonicalUid);
|
||||
},
|
||||
|
||||
/**
|
||||
* Filters app selection based on user permissions and visibility settings
|
||||
* @param {Object} options - Selection options including predicates
|
||||
@@ -123,16 +192,24 @@ class AppES extends BaseES {
|
||||
* @returns {Promise<Object>} Upsert operation results
|
||||
*/
|
||||
async upsert (entity, extra) {
|
||||
extra = extra || {};
|
||||
const actor = Context.get('actor');
|
||||
const user = actor?.type?.user;
|
||||
|
||||
const preJoinFullEntity = extra.old_entity
|
||||
? await (await extra.old_entity.clone()).apply(entity)
|
||||
: entity
|
||||
;
|
||||
await this.ensurePuterSiteSubdomainIsOwned(preJoinFullEntity, extra, user);
|
||||
|
||||
await this.maybe_join_owned_hosted_index_url_app_on_create_(entity, extra, user);
|
||||
|
||||
const full_entity = extra.old_entity
|
||||
? await (await extra.old_entity.clone()).apply(entity)
|
||||
: entity
|
||||
;
|
||||
|
||||
await this.ensure_puter_site_subdomain_is_owned_(full_entity, extra, user);
|
||||
await this.ensure_index_url_unique_(full_entity, extra);
|
||||
await this.ensureIndexUrlUnique(full_entity, extra);
|
||||
|
||||
if ( await app_name_exists(await entity.get('name')) ) {
|
||||
const { old_entity } = extra;
|
||||
@@ -268,6 +345,17 @@ class AppES extends BaseES {
|
||||
});
|
||||
}
|
||||
|
||||
if ( extra.joined_source_app_uid ) {
|
||||
await this.write_canonical_app_uid_alias_({
|
||||
oldAppUid: extra.joined_source_app_uid,
|
||||
canonicalAppUid: await full_entity.get('uid'),
|
||||
});
|
||||
const svc_appInformation = this.context.get('services').get('app-information');
|
||||
if ( svc_appInformation?.delete_app ) {
|
||||
await svc_appInformation.delete_app(extra.joined_source_app_uid);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
async retry_predicate_rewrite ({ predicate }) {
|
||||
@@ -497,7 +585,7 @@ class AppES extends BaseES {
|
||||
* Ensures that when an app uses a puter.site subdomain as its index_url,
|
||||
* the subdomain belongs to the user creating/updating the app.
|
||||
*/
|
||||
async ensure_puter_site_subdomain_is_owned_ (entity, extra, user) {
|
||||
async ensurePuterSiteSubdomainIsOwned (entity, extra, user) {
|
||||
if ( ! user ) return;
|
||||
|
||||
// Only enforce when the index_url is being set or changed
|
||||
@@ -510,33 +598,7 @@ class AppES extends BaseES {
|
||||
}
|
||||
}
|
||||
|
||||
let hostname;
|
||||
try {
|
||||
hostname = (new URL(new_index_url)).hostname.toLowerCase();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const hostedDomains = [
|
||||
config.static_hosting_domain,
|
||||
config.static_hosting_domain_alt,
|
||||
config.private_app_hosting_domain,
|
||||
config.private_app_hosting_domain_alt,
|
||||
]
|
||||
.map(value => typeof value === 'string'
|
||||
? value.trim().toLowerCase().replace(/^\./, '').split(':')[0]
|
||||
: null)
|
||||
.filter(Boolean)
|
||||
.sort((domainA, domainB) => domainB.length - domainA.length);
|
||||
|
||||
let subdomain = null;
|
||||
for ( const hostedDomain of hostedDomains ) {
|
||||
const suffix = `.${hostedDomain}`;
|
||||
if ( hostname.endsWith(suffix) ) {
|
||||
subdomain = hostname.slice(0, hostname.length - suffix.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const subdomain = extractPuterHostedSubdomainFromIndexUrl(new_index_url);
|
||||
if ( ! subdomain ) return;
|
||||
|
||||
const svc_puterSite = this.context.get('services').get('puter-site');
|
||||
@@ -547,50 +609,52 @@ class AppES extends BaseES {
|
||||
}
|
||||
},
|
||||
|
||||
async ensure_index_url_unique_ (entity, extra) {
|
||||
const new_index_url = await entity.get('index_url');
|
||||
if ( ! new_index_url ) return;
|
||||
is_puter_hosted_index_url_ (index_url) {
|
||||
return !!extractPuterHostedSubdomainFromIndexUrl(index_url);
|
||||
},
|
||||
|
||||
if ( extra.old_entity ) {
|
||||
const old_index_url = await extra.old_entity.get('index_url');
|
||||
if ( old_index_url === new_index_url ) {
|
||||
return;
|
||||
}
|
||||
build_equivalent_index_url_candidates_ (index_url) {
|
||||
if ( typeof index_url !== 'string' || !index_url.trim() ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates = (() => {
|
||||
try {
|
||||
const parsedUrl = new URL(new_index_url);
|
||||
const origin = `${parsedUrl.protocol}//${parsedUrl.host.toLowerCase()}`;
|
||||
const pathname = parsedUrl.pathname || '/';
|
||||
const values = new Set();
|
||||
if ( pathname === '/' || pathname.toLowerCase() === '/index.html' ) {
|
||||
values.add(origin);
|
||||
values.add(`${origin}/`);
|
||||
values.add(`${origin}/index.html`);
|
||||
} else {
|
||||
const normalizedPath = pathname.endsWith('/')
|
||||
? pathname.slice(0, -1)
|
||||
: pathname;
|
||||
values.add(`${origin}${normalizedPath}`);
|
||||
values.add(`${origin}${normalizedPath}/`);
|
||||
}
|
||||
return [...values];
|
||||
} catch {
|
||||
return [new_index_url];
|
||||
try {
|
||||
const parsedUrl = new URL(index_url);
|
||||
const origin = `${parsedUrl.protocol}//${parsedUrl.host.toLowerCase()}`;
|
||||
const pathname = parsedUrl.pathname || '/';
|
||||
const values = new Set();
|
||||
if ( pathname === '/' || pathname.toLowerCase() === '/index.html' ) {
|
||||
values.add(origin);
|
||||
values.add(`${origin}/`);
|
||||
values.add(`${origin}/index.html`);
|
||||
} else {
|
||||
const normalizedPath = pathname.endsWith('/')
|
||||
? pathname.slice(0, -1)
|
||||
: pathname;
|
||||
values.add(`${origin}${normalizedPath}`);
|
||||
values.add(`${origin}${normalizedPath}/`);
|
||||
}
|
||||
})();
|
||||
return [...values];
|
||||
} catch {
|
||||
return [index_url.trim()];
|
||||
}
|
||||
},
|
||||
|
||||
if ( candidates.length === 0 ) return;
|
||||
if ( hasIndexUrlUniquenessExemption(candidates) ) return;
|
||||
async find_index_url_conflict_ ({ indexUrl, excludeMysqlId }) {
|
||||
if ( ! this.is_puter_hosted_index_url_(indexUrl) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = this.build_equivalent_index_url_candidates_(indexUrl);
|
||||
if ( candidates.length === 0 ) return null;
|
||||
if ( hasIndexUrlUniquenessExemption(candidates) ) return null;
|
||||
|
||||
const placeholders = candidates.map(() => '?').join(', ');
|
||||
const parameters = [...candidates];
|
||||
let query = `SELECT id, uid, index_url FROM apps WHERE index_url IN (${placeholders})`;
|
||||
const currentMysqlId = extra.old_entity?.private_meta?.mysql_id;
|
||||
if ( Number.isInteger(currentMysqlId) && currentMysqlId > 0 ) {
|
||||
let query = `SELECT id, uid, owner_user_id, index_url FROM apps WHERE index_url IN (${placeholders})`;
|
||||
if ( Number.isInteger(excludeMysqlId) && excludeMysqlId > 0 ) {
|
||||
query += ' AND id != ?';
|
||||
parameters.push(currentMysqlId);
|
||||
parameters.push(excludeMysqlId);
|
||||
}
|
||||
query += ' ORDER BY timestamp ASC, id ASC LIMIT 1';
|
||||
|
||||
@@ -601,6 +665,174 @@ class AppES extends BaseES {
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return conflictRow || null;
|
||||
},
|
||||
|
||||
async claim_app_ownership_by_id_for_user_ ({ appId, userId }) {
|
||||
if ( !Number.isInteger(appId) || appId <= 0 ) return;
|
||||
if ( !Number.isInteger(userId) || userId <= 0 ) return;
|
||||
|
||||
await this.db.write(
|
||||
'UPDATE apps SET owner_user_id = ? WHERE id = ? AND owner_user_id IS NULL',
|
||||
[userId, appId],
|
||||
);
|
||||
},
|
||||
|
||||
build_canonical_app_uid_alias_key_ (oldAppUid) {
|
||||
return `${APP_UID_ALIAS_KEY_PREFIX}:${oldAppUid}`;
|
||||
},
|
||||
|
||||
async read_canonical_app_uid_alias_ (oldAppUid) {
|
||||
if ( typeof oldAppUid !== 'string' || !oldAppUid ) return null;
|
||||
|
||||
const services = this.context.get('services');
|
||||
const kvStore = services.get('puter-kvstore');
|
||||
const suService = services.get('su');
|
||||
if ( !kvStore || typeof kvStore.get !== 'function' ) return null;
|
||||
if ( !suService || typeof suService.sudo !== 'function' ) return null;
|
||||
|
||||
const key = this.build_canonical_app_uid_alias_key_(oldAppUid);
|
||||
try {
|
||||
const canonicalAppUid = await suService.sudo(() => kvStore.get({ key }));
|
||||
if ( typeof canonicalAppUid === 'string' && canonicalAppUid ) {
|
||||
return canonicalAppUid;
|
||||
}
|
||||
} catch {
|
||||
// Alias reads are best-effort.
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
async write_canonical_app_uid_alias_ ({ oldAppUid, canonicalAppUid }) {
|
||||
if ( typeof oldAppUid !== 'string' || !oldAppUid ) return;
|
||||
if ( typeof canonicalAppUid !== 'string' || !canonicalAppUid ) return;
|
||||
if ( oldAppUid === canonicalAppUid ) return;
|
||||
|
||||
const services = this.context.get('services');
|
||||
const kvStore = services.get('puter-kvstore');
|
||||
const suService = services.get('su');
|
||||
if ( !kvStore || typeof kvStore.set !== 'function' ) return;
|
||||
if ( !suService || typeof suService.sudo !== 'function' ) return;
|
||||
|
||||
const key = this.build_canonical_app_uid_alias_key_(oldAppUid);
|
||||
try {
|
||||
await suService.sudo(() => kvStore.set({
|
||||
key,
|
||||
value: canonicalAppUid,
|
||||
}));
|
||||
} catch {
|
||||
// Alias writes are best-effort.
|
||||
}
|
||||
},
|
||||
|
||||
async maybe_join_owned_hosted_index_url_app_on_create_ (entity, extra, user) {
|
||||
if ( ! user ) return;
|
||||
|
||||
const new_index_url = await entity.get('index_url');
|
||||
const source_entity = extra.old_entity;
|
||||
const currentMysqlId = extra.old_entity?.private_meta?.mysql_id;
|
||||
const conflictRow = await this.find_index_url_conflict_({
|
||||
indexUrl: new_index_url,
|
||||
excludeMysqlId: currentMysqlId,
|
||||
});
|
||||
if ( ! conflictRow ) return;
|
||||
|
||||
const conflictOwnerUserId = Number(conflictRow.owner_user_id);
|
||||
if (
|
||||
Number.isInteger(conflictOwnerUserId)
|
||||
&& conflictOwnerUserId > 0
|
||||
&& conflictOwnerUserId !== user.id
|
||||
) {
|
||||
throw APIError.create('app_index_url_already_in_use', null, {
|
||||
index_url: new_index_url,
|
||||
app_uid: conflictRow.uid,
|
||||
});
|
||||
}
|
||||
|
||||
if ( !Number.isInteger(conflictOwnerUserId) || conflictOwnerUserId <= 0 ) {
|
||||
await this.claim_app_ownership_by_id_for_user_({
|
||||
appId: conflictRow.id,
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
const old_entity = await this.upstream.read(conflictRow.uid);
|
||||
const owner = await old_entity?.get('owner');
|
||||
let ownerUserId = owner?.id ?? owner;
|
||||
if ( owner instanceof Entity ) {
|
||||
ownerUserId = owner.private_meta.mysql_id;
|
||||
}
|
||||
ownerUserId = Number(ownerUserId);
|
||||
if ( !old_entity || !Number.isInteger(ownerUserId) || ownerUserId !== user.id ) {
|
||||
throw APIError.create('app_index_url_already_in_use', null, {
|
||||
index_url: new_index_url,
|
||||
app_uid: conflictRow.uid,
|
||||
});
|
||||
}
|
||||
if (
|
||||
Number.isInteger(conflictOwnerUserId)
|
||||
&& conflictOwnerUserId === user.id
|
||||
&& !await this.is_origin_bootstrap_app_entity_(old_entity)
|
||||
) {
|
||||
// Prevent merging arbitrary same-owner apps; only allow the
|
||||
// auto-created origin bootstrap app to be absorbed.
|
||||
throw APIError.create('app_index_url_already_in_use', null, {
|
||||
index_url: new_index_url,
|
||||
app_uid: conflictRow.uid,
|
||||
});
|
||||
}
|
||||
|
||||
if ( source_entity ) {
|
||||
const sourceUid = await source_entity.get('uid');
|
||||
const targetUid = await old_entity.get('uid');
|
||||
const requestedName = await entity.get('name');
|
||||
|
||||
if (
|
||||
sourceUid
|
||||
&& targetUid
|
||||
&& sourceUid !== targetUid
|
||||
&& requestedName !== undefined
|
||||
) {
|
||||
entity.del('name');
|
||||
}
|
||||
|
||||
if ( sourceUid && targetUid && sourceUid !== targetUid ) {
|
||||
extra.joined_source_app_uid = sourceUid;
|
||||
}
|
||||
}
|
||||
|
||||
await entity.set('uid', await old_entity.get('uid'));
|
||||
extra.old_entity = old_entity;
|
||||
},
|
||||
|
||||
async is_origin_bootstrap_app_entity_ (entity) {
|
||||
if ( ! entity ) return false;
|
||||
const uid = await entity.get('uid');
|
||||
if ( typeof uid !== 'string' || !uid ) return false;
|
||||
if ( await entity.get('name') !== uid ) return false;
|
||||
if ( await entity.get('title') !== uid ) return false;
|
||||
const description = await entity.get('description');
|
||||
if ( typeof description !== 'string' ) return false;
|
||||
return description.startsWith('App created from origin ');
|
||||
},
|
||||
|
||||
async ensureIndexUrlUnique (entity, extra) {
|
||||
const new_index_url = await entity.get('index_url');
|
||||
if ( ! new_index_url ) return;
|
||||
if ( ! this.is_puter_hosted_index_url_(new_index_url) ) return;
|
||||
|
||||
if ( extra.old_entity ) {
|
||||
const old_index_url = await extra.old_entity.get('index_url');
|
||||
if ( old_index_url === new_index_url ) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const currentMysqlId = extra.old_entity?.private_meta?.mysql_id;
|
||||
const conflictRow = await this.find_index_url_conflict_({
|
||||
indexUrl: new_index_url,
|
||||
excludeMysqlId: currentMysqlId,
|
||||
});
|
||||
if ( conflictRow ) {
|
||||
throw APIError.create('app_index_url_already_in_use', null, {
|
||||
index_url: new_index_url,
|
||||
|
||||
Reference in New Issue
Block a user