337 lines
9.6 KiB
TypeScript
337 lines
9.6 KiB
TypeScript
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<string | null> {
|
|
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<boolean> {
|
|
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,
|
|
});
|
|
}
|
|
}
|