diff --git a/src/backend/src/modules/data-access/AppService.js b/src/backend/src/modules/data-access/AppService.js index 28540864a..9302497f6 100644 --- a/src/backend/src/modules/data-access/AppService.js +++ b/src/backend/src/modules/data-access/AppService.js @@ -1,3 +1,4 @@ +import APIError from '../../api/APIError.js'; import BaseService from '../../services/BaseService.js'; import { DB_READ } from '../../services/database/consts.js'; import { Context } from '../../util/context.js'; @@ -27,7 +28,7 @@ export default class AppService extends BaseService { // TODO }, async read ({ uid, id, params = {} }) { - // TODO + return this.#read({ uid, id, params }); }, async select (options) { return this.#select(options); @@ -38,6 +39,9 @@ export default class AppService extends BaseService { }, }; + // value of require('om/mappings/app.js').redundant_identifiers + static REDUNDANT_IDENTIFIERS = ['name']; + async #select ({ predicate, params, ...rest }) { const db = this.db; @@ -146,4 +150,161 @@ export default class AppService extends BaseService { return client_safe_apps; } + + async #read ({ uid, id, params = {} }) { + const db = this.db; + + if ( uid === undefined && id === undefined ) { + throw new Error('read requires either uid or id'); + } + + const sql_associatedFiletypes = this.db.case({ + mysql: 'COALESCE(JSON_ARRAYAGG(afa.type), JSON_ARRAY())', + sqlite: "COALESCE(json_group_array(afa.type), json('[]'))", + }); + + // Build WHERE clause based on identifier type + let whereClause; + let whereValues; + + if ( uid !== undefined ) { + // Simple uid lookup + whereClause = 'apps.uid = ?'; + whereValues = [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); + whereClause = clause; + whereValues = values; + } else { + throw APIError.create('invalid_id', null, { id }); + } + + const stmt = 'SELECT apps.*, ' + + 'owner_user.username AS owner_user_username, ' + + 'owner_user.uuid AS owner_user_uuid, ' + + 'app_owner.uid AS app_owner_uid, ' + + `${sql_associatedFiletypes} AS filetypes ` + + 'FROM apps ' + + 'LEFT JOIN user owner_user ON apps.owner_user_id = owner_user.id ' + + 'LEFT JOIN apps app_owner ON apps.app_owner = app_owner.id ' + + 'LEFT JOIN app_filetype_association afa ON apps.id = afa.app_id ' + + `WHERE ${whereClause} ` + + 'GROUP BY apps.id ' + + 'LIMIT 1'; + + const rows = await db.read(stmt, whereValues); + + if ( rows.length === 0 ) { + return undefined; + } + + const row = rows[0]; + const app = {}; + + app.approved_for_incentive_program = as_bool(row.approved_for_incentive_program); + app.approved_for_listing = as_bool(row.approved_for_listing); + app.approved_for_opening_items = as_bool(row.approved_for_opening_items); + app.background = as_bool(row.background); + app.created_at = row.created_at; + app.created_from_origin = row.created_from_origin; + app.description = row.description; + app.godmode = as_bool(row.godmode); + app.icon = row.icon; + app.index_url = row.index_url; + app.maximize_on_start = as_bool(row.maximize_on_start); + app.metadata = row.metadata; + app.name = row.name; + app.protected = as_bool(row.protected); + app.stats = row.stats; + app.title = row.title; + app.uid = row.uid; + + app.app_owner = { + uid: row.app_owner_uid, + }; + + { + const owner_user = extract_from_prefix(row, 'owner_user_'); + app.owner_user = user_to_client(owner_user); + } + + if ( typeof row.filetypes === 'string' ) { + try { + const filetypesAsJSON = JSON.parse(row.filetypes); + for ( let i = 0 ; i < filetypesAsJSON.length ; i++ ) { + if ( filetypesAsJSON[i] === null ) continue; + if ( typeof filetypesAsJSON[i] !== 'string' ) { + throw new Error(`expected filetypesAsJSON[${i}] to be a string, got: ${filetypesAsJSON[i]}`); + } + if ( String.prototype.startsWith.call(filetypesAsJSON[i], '.') ) { + filetypesAsJSON[i] = filetypesAsJSON[i].slice(1); + } + } + app.filetype_associations = filetypesAsJSON; + } catch (e) { + throw new Error(`failed to get app filetype associations: ${e.message}`, { cause: e }); + } + } + + if ( params.icon_size ) { + const icon_size = params.icon_size; + const svc_appIcon = this.context.get('services').get('app-icon'); + try { + const icon_result = await svc_appIcon.get_icon_stream({ + app_uid: row.uid, + app_icon: row.icon, + size: icon_size, + }); + app.icon = await icon_result.get_data_url(); + } catch (e) { + const svc_error = this.context.get('services').get('error-service'); + svc_error.report('AppES:read_transform', { source: e }); + } + } + + return app; + } + + #build_complex_id_where (id) { + const id_keys = Object.keys(id); + id_keys.sort(); + + // 1. Validate the identifier key from `id` + + const redundant_identifiers = this.constructor.REDUNDANT_IDENTIFIERS; + let match_found = false; + + for ( let key_set of redundant_identifiers ) { + key_set = Array.isArray(key_set) ? key_set : [key_set]; + const sorted_key_set = [...key_set].sort(); + + // Check if id_keys matches this key_set exactly + if ( id_keys.length === sorted_key_set.length && + id_keys.every((k, i) => k === sorted_key_set[i]) ) { + match_found = true; + break; + } + } + + if ( ! match_found ) { + throw new Error(`Invalid complex id keys: ${id_keys.join(', ')}. ` + + `Allowed: ${redundant_identifiers.join(', ')}`); + } + + // 2. Build the SQL string for the predicate + + const conditions = []; + const values = []; + + for ( const key of id_keys ) { + conditions.push(`apps.${key} = ?`); + values.push(id[key]); + } + + return { + clause: conditions.join(' AND '), + values, + }; + } }