import { ForbiddenException, Injectable, NotFoundException, UnauthorizedException, } from '@nestjs/common'; import { AuthService } from '../auth/auth.service'; import { PrismaService } from '../prisma/prisma.service'; import { GrantAppPermissionDto } from './dto/grant-app-permission.dto'; type UpdateAction = 'INSTALL' | 'UPDATE' | 'LAUNCH' | 'UNAVAILABLE'; type UpdateStatus = 'NOT_INSTALLED' | 'UPDATE_AVAILABLE' | 'READY' | 'DISABLED' | 'UNKNOWN_APP'; type UpdateStatusInput = { currentVersion?: string; installed: boolean; accessToken?: string; }; type AuthMeResponse = { user?: { id?: string; role?: string; roles?: string[]; }; }; const APP_PERMISSION_ADMIN_ROLES = new Set(['ADMIN']); function normalizeVersion(version?: string): string | undefined { return version?.trim().replace(/^v/i, '') || undefined; } function compareVersions(left?: string, right?: string): number { const a = normalizeVersion(left); const b = normalizeVersion(right); if (!a && !b) return 0; if (!a) return -1; if (!b) return 1; const leftParts = a.split(/[.-]/).map((part) => Number.parseInt(part, 10)); const rightParts = b.split(/[.-]/).map((part) => Number.parseInt(part, 10)); const length = Math.max(leftParts.length, rightParts.length); for (let index = 0; index < length; index += 1) { const leftValue = Number.isNaN(leftParts[index]) ? 0 : leftParts[index] ?? 0; const rightValue = Number.isNaN(rightParts[index]) ? 0 : rightParts[index] ?? 0; if (leftValue > rightValue) return 1; if (leftValue < rightValue) return -1; } return 0; } @Injectable() export class AppsService { constructor( private readonly prisma: PrismaService, private readonly authService: AuthService, ) {} async getApps(accessToken?: string) { const authUserId = await this.getAuthUserId(accessToken); if (authUserId) { await this.ensureDefaultAppViewPermissions(authUserId, accessToken!); } const apps = await this.prisma.launcherApp.findMany({ where: { isEnabled: true }, orderBy: [{ sortOrder: 'asc' }, { displayName: 'asc' }], select: { appKey: true, displayName: true, description: true, subtitle: true, detailUrl: true, videoPath: true, iconKey: true, launchUrl: true, devAppPath: true, execPath: true, sortOrder: true, userApps: authUserId ? { where: { authUserId }, select: { canView: true, canLaunch: true, }, take: 1, } : false, }, }); return { apps: apps.map((app) => { const permission = app.userApps?.[0]; const canView = permission?.canView ?? false; return { appKey: app.appKey, displayName: app.displayName, description: app.description, subtitle: app.subtitle, detailUrl: app.detailUrl, videoPath: app.videoPath, iconKey: app.iconKey, launchUrl: app.launchUrl, devAppPath: app.devAppPath, execPath: app.execPath, sortOrder: app.sortOrder, canView, canLaunch: canView && (permission?.canLaunch ?? false), }; }), }; } async getUpdateStatus(appKey: string, input: UpdateStatusInput) { const isLauncherUpdate = appKey.toLowerCase() === 'launcher'; const authUserId = isLauncherUpdate ? null : await this.getAuthUserId(input.accessToken); if (authUserId) { await this.ensureDefaultAppViewPermissions(authUserId, input.accessToken!); } const canLaunch = isLauncherUpdate ? true : authUserId ? await this.canLaunchApp(authUserId, appKey) : false; const appVersion = await this.prisma.launcherAppVersion.findUnique({ where: { appKey }, }); if (!appVersion) { return { appKey, installed: input.installed, localVersion: input.currentVersion ?? null, latestVersion: null, status: 'UNKNOWN_APP' satisfies UpdateStatus, action: 'UNAVAILABLE' satisfies UpdateAction, updateRequired: false, }; } if (!appVersion.isEnabled) { return { appKey, displayName: appVersion.displayName, installed: input.installed, localVersion: input.currentVersion ?? null, latestVersion: appVersion.latestVersion, status: 'DISABLED' satisfies UpdateStatus, action: 'UNAVAILABLE' satisfies UpdateAction, updateRequired: false, downloadUrl: appVersion.downloadUrl, releaseNotes: appVersion.releaseNotes, }; } if (!canLaunch) { return { appKey, displayName: appVersion.displayName, installed: input.installed, localVersion: input.currentVersion ?? null, latestVersion: appVersion.latestVersion, status: 'DISABLED' satisfies UpdateStatus, action: 'UNAVAILABLE' satisfies UpdateAction, updateRequired: false, downloadUrl: appVersion.downloadUrl, releaseNotes: appVersion.releaseNotes, canLaunch: false, }; } const installed = input.installed; const localVersion = normalizeVersion(input.currentVersion); const hasUpdate = installed && compareVersions(localVersion, appVersion.latestVersion) < 0; const status: UpdateStatus = !installed ? 'NOT_INSTALLED' : hasUpdate ? 'UPDATE_AVAILABLE' : 'READY'; const action: UpdateAction = status === 'NOT_INSTALLED' ? 'INSTALL' : status === 'UPDATE_AVAILABLE' ? 'UPDATE' : 'LAUNCH'; return { appKey, displayName: appVersion.displayName, installed, localVersion: localVersion ?? null, latestVersion: appVersion.latestVersion, minimumSupportedVersion: appVersion.minimumSupportedVersion, status, action, updateRequired: status === 'UPDATE_AVAILABLE', downloadUrl: appVersion.downloadUrl, releaseNotes: appVersion.releaseNotes, canLaunch: true, }; } async grantAppPermission( appKey: string, authUserId: string, input: GrantAppPermissionDto, accessToken?: string, ) { const actor = await this.requireAppPermissionAdmin(accessToken); const app = await this.prisma.launcherApp.findUnique({ where: { appKey }, select: { id: true, appKey: true }, }); if (!app) throw new NotFoundException('App not found'); const permission = await this.prisma.launcherUserApp.upsert({ where: { authUserId_appId: { authUserId, appId: app.id, }, }, update: { canView: input.canView ?? true, canLaunch: input.canLaunch ?? true, assignedById: actor.id, assignedAt: new Date(), }, create: { authUserId, appId: app.id, canView: input.canView ?? true, canLaunch: input.canLaunch ?? true, assignedById: actor.id, }, select: { id: true, authUserId: true, canView: true, canLaunch: true, assignedAt: true, app: { select: { appKey: true, displayName: true, }, }, }, }); return { permission }; } private async getAuthUserId(accessToken?: string): Promise { if (!accessToken) return null; try { const response = await this.authService.me(accessToken) as { user?: { id?: string } }; return response.user?.id ?? null; } catch { return null; } } private async requireAppPermissionAdmin(accessToken?: string) { if (!accessToken) throw new UnauthorizedException('Bearer token required'); const response = await this.authService.me(accessToken) as AuthMeResponse; const user = response.user; const roles = new Set( [user?.role, ...(user?.roles ?? [])] .filter(Boolean) .map((role) => String(role).toUpperCase()), ); if (!user?.id || ![...APP_PERMISSION_ADMIN_ROLES].some((role) => roles.has(role))) { throw new ForbiddenException('App permission admin role required'); } return { id: user.id }; } private async canLaunchApp(authUserId: string, appKey: string): Promise { const permission = await this.prisma.launcherUserApp.findFirst({ where: { authUserId, canView: true, canLaunch: true, app: { appKey, isEnabled: true, }, }, select: { id: true }, }); return Boolean(permission); } private async ensureDefaultAppViewPermissions(authUserId: string, accessToken: string) { const apps = await this.prisma.launcherApp.findMany({ where: { isEnabled: true }, select: { id: true, appKey: true }, }); if (!apps.length) return; const existing = await this.prisma.launcherUserApp.findMany({ where: { authUserId }, select: { appId: true }, }); const existingIds = new Set(existing.map((permission) => permission.appId)); const missing = apps.filter((app) => !existingIds.has(app.id)); if (!missing.length) return; // Do not persist deny defaults when policy lookup fails; retry on the next request. const policy = await this.authService.getLauncherAppPolicy(accessToken); const byAppKey = new Map(policy.permissions.map((permission) => [permission.appKey, permission.canLaunch])); await this.prisma.launcherUserApp.createMany({ data: missing.map((app) => ({ authUserId, appId: app.id, canView: true, canLaunch: byAppKey.get(app.appKey) ?? policy.defaultCanLaunch, })), skipDuplicates: true, }); } }