init studio launcher backend

This commit is contained in:
2026-07-01 18:01:05 +09:00
commit 702d093736
69 changed files with 14881 additions and 0 deletions
+299
View File
@@ -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);
}
}