init studio launcher backend
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user