init studio launcher backend
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { join } from 'path';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { AppsModule } from './apps/apps.module';
|
||||
import { NoticesModule } from './notices/notices.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { DownloadsModule } from './downloads/downloads.module';
|
||||
import { ProvisioningModule } from './provisioning/provisioning.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: join(__dirname, '..', 'public'),
|
||||
}),
|
||||
AuthModule,
|
||||
NoticesModule,
|
||||
NotificationsModule,
|
||||
DownloadsModule,
|
||||
ProvisioningModule,
|
||||
AppsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, Req } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { AppsService } from './apps.service';
|
||||
import { GrantAppPermissionDto } from './dto/grant-app-permission.dto';
|
||||
|
||||
@Controller('api/apps')
|
||||
export class AppsController {
|
||||
constructor(private readonly appsService: AppsService) {}
|
||||
|
||||
@Get()
|
||||
getApps(@Req() request: Request) {
|
||||
return this.appsService.getApps(this.getBearerToken(request));
|
||||
}
|
||||
|
||||
@Get(':appKey/update')
|
||||
getUpdateStatus(
|
||||
@Param('appKey') appKey: string,
|
||||
@Req() request: Request,
|
||||
@Query('currentVersion') currentVersion?: string,
|
||||
@Query('installed') installed?: string,
|
||||
) {
|
||||
return this.appsService.getUpdateStatus(appKey, {
|
||||
currentVersion,
|
||||
installed: installed === 'true',
|
||||
accessToken: this.getBearerToken(request),
|
||||
});
|
||||
}
|
||||
|
||||
@Post(':appKey/users/:authUserId/permission')
|
||||
grantAppPermission(
|
||||
@Param('appKey') appKey: string,
|
||||
@Param('authUserId') authUserId: string,
|
||||
@Body() body: GrantAppPermissionDto,
|
||||
@Req() request: Request,
|
||||
) {
|
||||
return this.appsService.grantAppPermission(
|
||||
appKey,
|
||||
authUserId,
|
||||
body,
|
||||
this.getBearerToken(request),
|
||||
);
|
||||
}
|
||||
|
||||
private getBearerToken(request: Request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { AppsController } from './apps.controller';
|
||||
import { AppsService } from './apps.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [AppsController],
|
||||
providers: [AppsService],
|
||||
})
|
||||
export class AppsModule {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
export class GrantAppPermissionDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
canView?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
canLaunch?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Body, Controller, Delete, Get, Post, Put, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { HandoffDto } from './dto/handoff.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
|
||||
@Controller('api/auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
login(
|
||||
@Body() loginDto: LoginDto,
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
return this.authService.login(
|
||||
loginDto.email,
|
||||
loginDto.password,
|
||||
request.headers.cookie,
|
||||
response,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
refresh(
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
return this.authService.refresh(request.headers.cookie, response);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
logout(
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
return this.authService.logout(request.headers.cookie, response);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
me(@Req() request: Request) {
|
||||
return this.authService.me(this.getBearerToken(request));
|
||||
}
|
||||
|
||||
@Post('password/change')
|
||||
changePassword(@Req() request: Request, @Body() body: ChangePasswordDto) {
|
||||
return this.authService.changePassword(
|
||||
this.getBearerToken(request),
|
||||
body.currentPassword,
|
||||
body.newPassword,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('profile/avatar')
|
||||
async getProfileAvatar(
|
||||
@Req() request: Request,
|
||||
@Res() response: Response,
|
||||
) {
|
||||
const result = await this.authService.getProfileAvatar(
|
||||
this.getBearerToken(request),
|
||||
);
|
||||
response.setHeader('Content-Type', result.contentType);
|
||||
response.setHeader('Cache-Control', 'no-store');
|
||||
response.send(result.body);
|
||||
}
|
||||
|
||||
@Put('profile/avatar')
|
||||
async updateProfileAvatar(@Req() request: Request) {
|
||||
const body = Buffer.isBuffer(request.body)
|
||||
? request.body
|
||||
: Buffer.from([]);
|
||||
await this.authService.updateProfileAvatar(
|
||||
this.getBearerToken(request),
|
||||
request.headers['content-type'],
|
||||
body,
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Delete('profile/avatar')
|
||||
deleteProfileAvatar(@Req() request: Request) {
|
||||
return this.authService.deleteProfileAvatar(this.getBearerToken(request));
|
||||
}
|
||||
|
||||
@Post('handoff')
|
||||
handoff(@Req() request: Request, @Body() body: HandoffDto) {
|
||||
return this.authService.handoff(
|
||||
this.getBearerToken(request),
|
||||
body.audienceApp ?? 'platform-ide',
|
||||
);
|
||||
}
|
||||
|
||||
private getBearerToken(request: Request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,297 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
BadGatewayException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Response } from 'express';
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
roles?: string[];
|
||||
};
|
||||
|
||||
type AuthTokenResponse = {
|
||||
accessToken: string;
|
||||
accessTokenExpiresIn?: number;
|
||||
user: AuthUser;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly authServerUrl: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.authServerUrl = this.configService
|
||||
.get<string>('AUTH_SERVER_URL', 'http://localhost:4100')
|
||||
.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
async login(
|
||||
email: string,
|
||||
password: string,
|
||||
cookieHeader: string | undefined,
|
||||
response: Response,
|
||||
) {
|
||||
const authResponse = await this.request<AuthTokenResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: { email, password },
|
||||
cookieHeader,
|
||||
response,
|
||||
unauthorizedMessage: 'Invalid email or password',
|
||||
});
|
||||
|
||||
return this.frontendTokenResponse(authResponse);
|
||||
}
|
||||
|
||||
async refresh(cookieHeader: string | undefined, response: Response) {
|
||||
const authResponse = await this.request<AuthTokenResponse>(
|
||||
'/auth/refresh',
|
||||
{
|
||||
method: 'POST',
|
||||
cookieHeader,
|
||||
response,
|
||||
unauthorizedMessage: 'Invalid session',
|
||||
},
|
||||
);
|
||||
|
||||
return this.frontendTokenResponse(authResponse);
|
||||
}
|
||||
|
||||
async logout(cookieHeader: string | undefined, response: Response) {
|
||||
await this.request('/auth/logout', {
|
||||
method: 'POST',
|
||||
cookieHeader,
|
||||
response,
|
||||
ignoreAuthErrors: true,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async me(accessToken: string | undefined) {
|
||||
return this.request('/auth/me', {
|
||||
method: 'GET',
|
||||
accessToken,
|
||||
unauthorizedMessage: 'Invalid bearer token',
|
||||
});
|
||||
}
|
||||
|
||||
async handoff(accessToken: string | undefined, audienceApp: string) {
|
||||
return this.request('/auth/handoff', {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: { audienceApp },
|
||||
unauthorizedMessage: 'Invalid bearer token',
|
||||
});
|
||||
}
|
||||
|
||||
async changePassword(
|
||||
accessToken: string | undefined,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
) {
|
||||
return this.request('/auth/password/change', {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: { currentPassword, newPassword },
|
||||
unauthorizedMessage: 'Invalid bearer token',
|
||||
});
|
||||
}
|
||||
|
||||
async getProfileAvatar(accessToken: string | undefined) {
|
||||
return this.binaryRequest('/auth/profile/avatar', {
|
||||
method: 'GET',
|
||||
accessToken,
|
||||
unauthorizedMessage: 'Invalid bearer token',
|
||||
});
|
||||
}
|
||||
|
||||
async updateProfileAvatar(
|
||||
accessToken: string | undefined,
|
||||
contentType: string | undefined,
|
||||
body: Buffer,
|
||||
) {
|
||||
return this.binaryRequest('/auth/profile/avatar', {
|
||||
method: 'PUT',
|
||||
accessToken,
|
||||
contentType,
|
||||
body,
|
||||
unauthorizedMessage: 'Invalid bearer token',
|
||||
});
|
||||
}
|
||||
|
||||
async deleteProfileAvatar(accessToken: string | undefined) {
|
||||
await this.binaryRequest('/auth/profile/avatar', {
|
||||
method: 'DELETE',
|
||||
accessToken,
|
||||
unauthorizedMessage: 'Invalid bearer token',
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private frontendTokenResponse(authResponse: AuthTokenResponse) {
|
||||
return {
|
||||
accessToken: authResponse.accessToken,
|
||||
accessTokenExpiresIn: authResponse.accessTokenExpiresIn,
|
||||
tokenType: 'Bearer',
|
||||
user: authResponse.user,
|
||||
};
|
||||
}
|
||||
|
||||
private async request<T = unknown>(
|
||||
path: string,
|
||||
options: {
|
||||
method: 'GET' | 'POST';
|
||||
body?: unknown;
|
||||
accessToken?: string;
|
||||
cookieHeader?: string;
|
||||
response?: Response;
|
||||
unauthorizedMessage?: string;
|
||||
ignoreAuthErrors?: boolean;
|
||||
},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (options.body) headers['content-type'] = 'application/json';
|
||||
if (options.accessToken) {
|
||||
headers.authorization = `Bearer ${options.accessToken}`;
|
||||
}
|
||||
if (options.cookieHeader) {
|
||||
headers.cookie = options.cookieHeader;
|
||||
}
|
||||
|
||||
let authResponse: globalThis.Response;
|
||||
try {
|
||||
authResponse = await fetch(`${this.authServerUrl}${path}`, {
|
||||
method: options.method,
|
||||
headers,
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
} catch {
|
||||
throw new BadGatewayException('Auth server is unavailable');
|
||||
}
|
||||
|
||||
this.forwardSetCookie(authResponse, options.response);
|
||||
|
||||
const payload: unknown = await authResponse.json().catch((): null => null);
|
||||
|
||||
if (authResponse.status === 401) {
|
||||
if (options.ignoreAuthErrors) return { ok: true } as T;
|
||||
throw new UnauthorizedException(
|
||||
options.unauthorizedMessage ?? 'Unauthorized',
|
||||
);
|
||||
}
|
||||
|
||||
if (authResponse.status === 400) {
|
||||
throw new BadRequestException(payload);
|
||||
}
|
||||
|
||||
if (!authResponse.ok) {
|
||||
throw new BadGatewayException({
|
||||
message: 'Auth server request failed',
|
||||
statusCode: authResponse.status,
|
||||
error: payload,
|
||||
});
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
private async binaryRequest(
|
||||
path: string,
|
||||
options: {
|
||||
method: 'GET' | 'PUT' | 'DELETE';
|
||||
accessToken?: string;
|
||||
contentType?: string;
|
||||
body?: Buffer;
|
||||
unauthorizedMessage?: string;
|
||||
},
|
||||
) {
|
||||
const headers: Record<string, string> = {};
|
||||
if (options.accessToken) headers.authorization = `Bearer ${options.accessToken}`;
|
||||
if (options.contentType) headers['content-type'] = options.contentType;
|
||||
|
||||
const requestBody = options.body
|
||||
? new Uint8Array(options.body).buffer
|
||||
: undefined;
|
||||
let authResponse: globalThis.Response;
|
||||
try {
|
||||
authResponse = await fetch(`${this.authServerUrl}${path}`, {
|
||||
method: options.method,
|
||||
headers,
|
||||
body: requestBody,
|
||||
});
|
||||
} catch {
|
||||
throw new BadGatewayException('Auth server is unavailable');
|
||||
}
|
||||
|
||||
if (authResponse.status === 401) {
|
||||
throw new UnauthorizedException(
|
||||
options.unauthorizedMessage ?? 'Unauthorized',
|
||||
);
|
||||
}
|
||||
|
||||
if (!authResponse.ok) {
|
||||
const payload: unknown = await authResponse.json().catch((): null => null);
|
||||
if (authResponse.status === 404) {
|
||||
throw new NotFoundException(payload);
|
||||
}
|
||||
if ([400, 413, 415].includes(authResponse.status)) {
|
||||
throw new BadRequestException(payload);
|
||||
}
|
||||
throw new BadGatewayException({
|
||||
message: 'Auth server request failed',
|
||||
statusCode: authResponse.status,
|
||||
error: payload,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
contentType: authResponse.headers.get('content-type') ?? 'application/octet-stream',
|
||||
body: Buffer.from(await authResponse.arrayBuffer()),
|
||||
};
|
||||
}
|
||||
|
||||
private forwardSetCookie(
|
||||
authResponse: globalThis.Response,
|
||||
response?: Response,
|
||||
) {
|
||||
if (!response) return;
|
||||
|
||||
const cookies = this.getSetCookieHeaders(authResponse.headers);
|
||||
|
||||
for (const cookie of cookies) {
|
||||
response.append('Set-Cookie', cookie);
|
||||
}
|
||||
}
|
||||
|
||||
private getSetCookieHeaders(headers: Headers): string[] {
|
||||
const maybeHeaders = headers as unknown as {
|
||||
getSetCookie?: unknown;
|
||||
};
|
||||
|
||||
if (typeof maybeHeaders.getSetCookie === 'function') {
|
||||
const cookies = maybeHeaders.getSetCookie.call(headers);
|
||||
return Array.isArray(cookies)
|
||||
? cookies.filter(
|
||||
(cookie): cookie is string => typeof cookie === 'string',
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
return this.splitSetCookieHeader(headers.get('set-cookie'));
|
||||
}
|
||||
|
||||
private splitSetCookieHeader(header: string | null): string[] {
|
||||
if (!header) return [];
|
||||
return header
|
||||
.split(/,(?=\s*[^;,=]+=[^;,]+)/)
|
||||
.map((cookie) => cookie.trim());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
currentPassword: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
newPassword: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class HandoffDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
audienceApp?: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { CreateDownloadFileDto } from './dto/create-download-file.dto';
|
||||
import { DownloadsService } from './downloads.service';
|
||||
|
||||
@Controller('api/downloads')
|
||||
export class DownloadsController {
|
||||
constructor(private readonly downloadsService: DownloadsService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query('appKey') appKey = 'all',
|
||||
@Query('contextKey') contextKey = 'general',
|
||||
) {
|
||||
return this.downloadsService.list(appKey, contextKey);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() body: CreateDownloadFileDto, @Req() request: Request) {
|
||||
return this.downloadsService.create(body, this.getBearerToken(request));
|
||||
}
|
||||
|
||||
@Get(':id/file')
|
||||
async download(
|
||||
@Param('id') id: string,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response,
|
||||
) {
|
||||
const result = await this.downloadsService.download(id, {
|
||||
accessToken: this.getBearerToken(request),
|
||||
userAgent: request.headers['user-agent'],
|
||||
ipAddress: request.ip,
|
||||
});
|
||||
|
||||
response.setHeader('Content-Type', result.contentType);
|
||||
response.setHeader('Content-Disposition', result.contentDisposition);
|
||||
response.setHeader('Cache-Control', 'no-store');
|
||||
if (result.contentLength) response.setHeader('Content-Length', result.contentLength);
|
||||
response.send(result.body);
|
||||
}
|
||||
|
||||
private getBearerToken(request: Request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { DownloadsController } from './downloads.controller';
|
||||
import { DownloadsService } from './downloads.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PrismaModule],
|
||||
controllers: [DownloadsController],
|
||||
providers: [DownloadsService],
|
||||
})
|
||||
export class DownloadsModule {}
|
||||
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
BadGatewayException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateDownloadFileDto } from './dto/create-download-file.dto';
|
||||
|
||||
type AuthMeResponse = {
|
||||
user?: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
type DownloadRequestMeta = {
|
||||
accessToken?: string;
|
||||
userAgent?: string;
|
||||
ipAddress?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DownloadsService {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async list(appKey: string, contextKey: string) {
|
||||
const files = await this.prisma.downloadFile.findMany({
|
||||
where: {
|
||||
appKey: appKey.trim() || 'all',
|
||||
contextKey: contextKey.trim() || 'general',
|
||||
isActive: true,
|
||||
},
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
|
||||
return {
|
||||
files: files.map((file) => ({
|
||||
id: file.id,
|
||||
appKey: file.appKey,
|
||||
contextKey: file.contextKey,
|
||||
title: file.title,
|
||||
description: file.description,
|
||||
fileName: file.fileName,
|
||||
contentType: file.contentType ?? inferContentType(file.fileName, file.sourceUrl),
|
||||
fileSizeBytes: file.fileSizeBytes?.toString() ?? null,
|
||||
uploadedByAppKey: file.uploadedByAppKey,
|
||||
sortOrder: file.sortOrder,
|
||||
downloadUrl: `/api/downloads/${file.id}/file`,
|
||||
createdAt: file.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async create(input: CreateDownloadFileDto, accessToken: string | undefined) {
|
||||
if (!accessToken) throw new UnauthorizedException('Missing bearer token');
|
||||
|
||||
const authUserId = await this.getAuthUserId(accessToken);
|
||||
const contentType = input.contentType?.trim() || inferContentType(input.fileName, input.sourceUrl);
|
||||
const fileSizeBytes = await this.readRemoteFileSize(input.sourceUrl).catch(() => null);
|
||||
|
||||
const file = await this.prisma.downloadFile.create({
|
||||
data: {
|
||||
appKey: input.appKey.trim(),
|
||||
contextKey: input.contextKey?.trim() || 'general',
|
||||
title: input.title.trim(),
|
||||
description: input.description?.trim() || null,
|
||||
fileName: input.fileName.trim(),
|
||||
sourceUrl: input.sourceUrl.trim(),
|
||||
contentType,
|
||||
fileSizeBytes,
|
||||
uploadedByAppKey: input.uploadedByAppKey?.trim() || input.appKey.trim(),
|
||||
uploadedByAuthId: authUserId,
|
||||
sortOrder: input.sortOrder ?? 0,
|
||||
isActive: input.isActive ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
file: {
|
||||
...file,
|
||||
fileSizeBytes: file.fileSizeBytes?.toString() ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async download(id: string, meta: DownloadRequestMeta) {
|
||||
const file = await this.prisma.downloadFile.findFirst({
|
||||
where: {
|
||||
id,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!file) throw new NotFoundException('Download file not found');
|
||||
|
||||
const remoteResponse = await fetch(file.sourceUrl).catch(() => null);
|
||||
if (!remoteResponse) throw new BadGatewayException('Download source is unavailable');
|
||||
|
||||
if (!remoteResponse.ok) {
|
||||
throw new BadGatewayException({
|
||||
message: 'Download source request failed',
|
||||
statusCode: remoteResponse.status,
|
||||
});
|
||||
}
|
||||
|
||||
const authUserId = meta.accessToken
|
||||
? await this.getAuthUserId(meta.accessToken).catch(() => null)
|
||||
: null;
|
||||
|
||||
await this.prisma.downloadEvent.create({
|
||||
data: {
|
||||
fileId: file.id,
|
||||
authUserId,
|
||||
appKey: file.appKey,
|
||||
userAgent: meta.userAgent ?? null,
|
||||
ipAddress: meta.ipAddress ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const contentType =
|
||||
file.contentType ||
|
||||
inferContentType(file.fileName, file.sourceUrl) ||
|
||||
remoteResponse.headers.get('content-type') ||
|
||||
'application/octet-stream';
|
||||
const contentLength = remoteResponse.headers.get('content-length') ?? file.fileSizeBytes?.toString() ?? null;
|
||||
|
||||
return {
|
||||
contentType,
|
||||
contentDisposition: buildContentDisposition(file.fileName),
|
||||
contentLength,
|
||||
body: Buffer.from(await remoteResponse.arrayBuffer()),
|
||||
};
|
||||
}
|
||||
|
||||
private async getAuthUserId(accessToken: string): Promise<string> {
|
||||
const response = (await this.authService.me(accessToken)) as AuthMeResponse;
|
||||
if (!response.user?.id) throw new UnauthorizedException('Invalid bearer token');
|
||||
return response.user.id;
|
||||
}
|
||||
|
||||
private async readRemoteFileSize(sourceUrl: string): Promise<bigint | null> {
|
||||
const response = await fetch(sourceUrl, { method: 'HEAD' });
|
||||
if (!response.ok) return null;
|
||||
const contentLength = response.headers.get('content-length');
|
||||
return contentLength && /^\d+$/.test(contentLength) ? BigInt(contentLength) : null;
|
||||
}
|
||||
}
|
||||
|
||||
function inferContentType(fileName: string, sourceUrl: string): string | null {
|
||||
const path = `${fileName || sourceUrl}`.split('?')[0]?.toLowerCase() ?? '';
|
||||
if (path.endsWith('.pdf')) return 'application/pdf';
|
||||
if (path.endsWith('.zip')) return 'application/zip';
|
||||
if (path.endsWith('.json')) return 'application/json';
|
||||
if (path.endsWith('.txt')) return 'text/plain; charset=utf-8';
|
||||
if (path.endsWith('.png')) return 'image/png';
|
||||
if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'image/jpeg';
|
||||
if (path.endsWith('.webp')) return 'image/webp';
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildContentDisposition(fileName: string): string {
|
||||
const fallback = fileName.replace(/[^\w. -]/g, '_') || 'download';
|
||||
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateDownloadFileDto {
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
appKey: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
contextKey?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
title: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
fileName: string;
|
||||
|
||||
@IsUrl({ require_protocol: true })
|
||||
@MaxLength(2000)
|
||||
sourceUrl: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
contentType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
uploadedByAppKey?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { raw } from 'express';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const configService = app.get(ConfigService);
|
||||
const port = configService.get<number>('PORT', 4200);
|
||||
const corsOrigin = configService.get<string>('CORS_ORIGIN', '*');
|
||||
const configuredOrigins = corsOrigin
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
const isLocalDevelopmentOrigin = (origin: string): boolean => {
|
||||
try {
|
||||
const url = new URL(origin);
|
||||
return ['localhost', '127.0.0.1'].includes(url.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
app.enableCors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin || corsOrigin === '*' || configuredOrigins.includes(origin) || isLocalDevelopmentOrigin(origin)) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
callback(new Error(`CORS origin not allowed: ${origin}`));
|
||||
},
|
||||
credentials: true,
|
||||
});
|
||||
app.use('/api/auth/profile/avatar', raw({ type: ['image/webp', 'image/png', 'image/jpeg'], limit: '80kb' }));
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await app.listen(port);
|
||||
}
|
||||
void bootstrap();
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { NoticesService } from './notices.service';
|
||||
|
||||
@Controller('api/notices')
|
||||
export class NoticesController {
|
||||
constructor(private readonly noticesService: NoticesService) {}
|
||||
|
||||
@Get('login')
|
||||
getLoginNotice() {
|
||||
return this.noticesService.getLoginNotice();
|
||||
}
|
||||
|
||||
@Get('apps/:appKey')
|
||||
getAppNotices(@Param('appKey') appKey: string) {
|
||||
return this.noticesService.getAppNotices(appKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { NoticesController } from './notices.controller';
|
||||
import { NoticesService } from './notices.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [NoticesController],
|
||||
providers: [NoticesService],
|
||||
})
|
||||
export class NoticesModule {}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class NoticesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getLoginNotice() {
|
||||
const now = new Date();
|
||||
|
||||
const notice = await this.prisma.launcherNotice.findFirst({
|
||||
where: {
|
||||
appKey: 'Launcher',
|
||||
isActive: true,
|
||||
OR: [
|
||||
{ startsAt: null, endsAt: null },
|
||||
{ startsAt: { lte: now }, endsAt: null },
|
||||
{ startsAt: null, endsAt: { gte: now } },
|
||||
{ startsAt: { lte: now }, endsAt: { gte: now } },
|
||||
],
|
||||
},
|
||||
orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
body: true,
|
||||
linkUrl: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { notice };
|
||||
}
|
||||
|
||||
async getAppNotices(appKey: string) {
|
||||
const now = new Date();
|
||||
|
||||
const notices = await this.prisma.launcherNotice.findMany({
|
||||
where: {
|
||||
appKey,
|
||||
isActive: true,
|
||||
OR: [
|
||||
{ startsAt: null, endsAt: null },
|
||||
{ startsAt: { lte: now }, endsAt: null },
|
||||
{ startsAt: null, endsAt: { gte: now } },
|
||||
{ startsAt: { lte: now }, endsAt: { gte: now } },
|
||||
],
|
||||
},
|
||||
orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
body: true,
|
||||
linkUrl: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
notices: notices.map((notice) => ({
|
||||
...notice,
|
||||
date: formatNoticeDate(notice.createdAt),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatNoticeDate(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
|
||||
return `${year}.${month}.${day}`;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { IsInt, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateNotificationDto {
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
appKey: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
audience?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
category?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
body: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl({ require_protocol: true })
|
||||
@MaxLength(2000)
|
||||
linkUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
priority?: number;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, Req } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { CreateNotificationDto } from './dto/create-notification.dto';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
type ReadBody = {
|
||||
notificationIds?: string[];
|
||||
appKey?: string;
|
||||
audience?: string;
|
||||
query?: string;
|
||||
};
|
||||
|
||||
@Controller('api/notifications')
|
||||
export class NotificationsController {
|
||||
constructor(private readonly notificationsService: NotificationsService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query('appKey') appKey = 'all',
|
||||
@Query('audience') audience = 'all',
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '50',
|
||||
@Query('query') query = '',
|
||||
@Query('read') read = 'all',
|
||||
@Req() request: Request,
|
||||
) {
|
||||
return this.notificationsService.list({
|
||||
appKey,
|
||||
audience,
|
||||
page,
|
||||
pageSize,
|
||||
query,
|
||||
read,
|
||||
accessToken: this.getBearerToken(request),
|
||||
});
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() body: CreateNotificationDto, @Req() request: Request) {
|
||||
return this.notificationsService.create(body, this.getBearerToken(request));
|
||||
}
|
||||
|
||||
@Post(':id/read')
|
||||
markRead(@Param('id') id: string, @Req() request: Request) {
|
||||
return this.notificationsService.markRead([id], this.getBearerToken(request));
|
||||
}
|
||||
|
||||
@Post('read-all')
|
||||
markReadAll(@Body() body: ReadBody, @Req() request: Request) {
|
||||
return this.notificationsService.markReadAll(body, this.getBearerToken(request));
|
||||
}
|
||||
|
||||
private getBearerToken(request: Request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule, PrismaModule],
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateNotificationDto } from './dto/create-notification.dto';
|
||||
|
||||
type AuthMeResponse = {
|
||||
user?: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ListInput = {
|
||||
appKey: string;
|
||||
audience: string;
|
||||
page?: string;
|
||||
pageSize?: string;
|
||||
query?: string;
|
||||
read?: string;
|
||||
accessToken?: string;
|
||||
};
|
||||
|
||||
type MarkAllReadInput = {
|
||||
notificationIds?: string[];
|
||||
appKey?: string;
|
||||
audience?: string;
|
||||
query?: string;
|
||||
};
|
||||
|
||||
const NOTIFICATION_CATEGORY_GROUP = 'NOTIFICATION_CATEGORY';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
private readonly authServerUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {
|
||||
this.authServerUrl = this.configService
|
||||
.get<string>('AUTH_SERVER_URL', 'http://localhost:4100')
|
||||
.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
async list(input: ListInput) {
|
||||
const now = new Date();
|
||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const appKey = input.appKey.trim() || 'all';
|
||||
const audience = input.audience.trim() || 'all';
|
||||
const page = Math.max(1, Number.parseInt(input.page ?? '1', 10) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, Number.parseInt(input.pageSize ?? '50', 10) || 50));
|
||||
const query = input.query?.trim() ?? '';
|
||||
const readFilter = input.read === 'read' || input.read === 'unread' ? input.read : 'all';
|
||||
const authUserId = input.accessToken ? await this.getAuthUserId(input.accessToken).catch(() => null) : null;
|
||||
|
||||
const notifications = await this.prisma.notification.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
appKey: { in: ['all', appKey] },
|
||||
audience: { in: ['all', audience] },
|
||||
createdAt: {
|
||||
gte: oneDayAgo,
|
||||
lte: now,
|
||||
},
|
||||
...(query
|
||||
? {
|
||||
OR: [{ title: { contains: query } }, { body: { contains: query } }, { category: { contains: query } }],
|
||||
}
|
||||
: {}),
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ startsAt: null, endsAt: null },
|
||||
{ startsAt: { lte: now }, endsAt: null },
|
||||
{ startsAt: null, endsAt: { gte: now } },
|
||||
{ startsAt: { lte: now }, endsAt: { gte: now } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
|
||||
select: {
|
||||
id: true,
|
||||
appKey: true,
|
||||
audience: true,
|
||||
category: true,
|
||||
title: true,
|
||||
body: true,
|
||||
linkUrl: true,
|
||||
createdAt: true,
|
||||
reads: authUserId
|
||||
? {
|
||||
where: { authUserId },
|
||||
select: { readAt: true },
|
||||
take: 1,
|
||||
}
|
||||
: false,
|
||||
},
|
||||
});
|
||||
const categoryCodes = [...new Set(notifications.map((notification) => notification.category))];
|
||||
const commonCodes = categoryCodes.length
|
||||
? await this.prisma.commonCode.findMany({
|
||||
where: {
|
||||
groupKey: NOTIFICATION_CATEGORY_GROUP,
|
||||
code: { in: categoryCodes },
|
||||
isActive: true,
|
||||
},
|
||||
select: {
|
||||
code: true,
|
||||
label: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const categoryLabelByCode = new Map(commonCodes.map((code) => [code.code, code.label]));
|
||||
|
||||
const mappedNotifications = notifications
|
||||
.map((notification) => ({
|
||||
id: notification.id,
|
||||
appKey: notification.appKey,
|
||||
audience: notification.audience,
|
||||
category: notification.category,
|
||||
categoryLabel: categoryLabelByCode.get(notification.category) ?? notification.category,
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
linkUrl: notification.linkUrl,
|
||||
createdAt: notification.createdAt.toISOString(),
|
||||
date: formatNotificationDate(notification.createdAt),
|
||||
read: authUserId ? notification.reads.length > 0 : false,
|
||||
}))
|
||||
.filter((notification) => {
|
||||
if (readFilter === 'read') return notification.read;
|
||||
if (readFilter === 'unread') return !notification.read;
|
||||
return true;
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (left.read !== right.read) return left.read ? 1 : -1;
|
||||
return new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime();
|
||||
});
|
||||
|
||||
const total = mappedNotifications.length;
|
||||
const unreadCount = mappedNotifications.filter((notification) => !notification.read).length;
|
||||
const start = (page - 1) * pageSize;
|
||||
|
||||
return {
|
||||
notifications: mappedNotifications.slice(start, start + pageSize),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
unreadCount,
|
||||
retentionHours: 24,
|
||||
};
|
||||
}
|
||||
|
||||
async create(input: CreateNotificationDto, accessToken: string | undefined) {
|
||||
if (!accessToken) throw new UnauthorizedException('Missing bearer token');
|
||||
await this.getAuthUserId(accessToken);
|
||||
|
||||
const notification = await this.prisma.notification.create({
|
||||
data: {
|
||||
appKey: input.appKey.trim(),
|
||||
audience: input.audience?.trim() || 'all',
|
||||
category: input.category?.trim() || 'notice',
|
||||
title: input.title.trim(),
|
||||
body: input.body.trim(),
|
||||
linkUrl: input.linkUrl?.trim() || null,
|
||||
priority: input.priority ?? 0,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
notification: {
|
||||
id: notification.id,
|
||||
appKey: notification.appKey,
|
||||
audience: notification.audience,
|
||||
category: notification.category,
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
linkUrl: notification.linkUrl,
|
||||
createdAt: notification.createdAt.toISOString(),
|
||||
date: formatNotificationDate(notification.createdAt),
|
||||
read: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async markRead(notificationIds: string[], accessToken: string | undefined) {
|
||||
if (!notificationIds.length) return { ok: true, readCount: 0 };
|
||||
if (!accessToken) throw new UnauthorizedException('Missing bearer token');
|
||||
|
||||
const authUserId = await this.getAuthUserId(accessToken);
|
||||
const ids = [...new Set(notificationIds)];
|
||||
|
||||
await this.prisma.$transaction(
|
||||
ids.map((notificationId) =>
|
||||
this.prisma.notificationRead.upsert({
|
||||
where: {
|
||||
notificationId_authUserId: {
|
||||
notificationId,
|
||||
authUserId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
notificationId,
|
||||
authUserId,
|
||||
},
|
||||
update: {
|
||||
readAt: new Date(),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return { ok: true, readCount: ids.length };
|
||||
}
|
||||
|
||||
async markReadAll(input: MarkAllReadInput, accessToken: string | undefined) {
|
||||
if (!accessToken) throw new UnauthorizedException('Missing bearer token');
|
||||
|
||||
if (input.notificationIds?.length) {
|
||||
return this.markRead(input.notificationIds, accessToken);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const appKey = input.appKey?.trim() || 'all';
|
||||
const audience = input.audience?.trim() || 'all';
|
||||
const query = input.query?.trim() ?? '';
|
||||
|
||||
const notifications = await this.prisma.notification.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
appKey: { in: ['all', appKey] },
|
||||
audience: { in: ['all', audience] },
|
||||
createdAt: {
|
||||
gte: oneDayAgo,
|
||||
lte: now,
|
||||
},
|
||||
...(query
|
||||
? {
|
||||
OR: [{ title: { contains: query } }, { body: { contains: query } }, { category: { contains: query } }],
|
||||
}
|
||||
: {}),
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ startsAt: null, endsAt: null },
|
||||
{ startsAt: { lte: now }, endsAt: null },
|
||||
{ startsAt: null, endsAt: { gte: now } },
|
||||
{ startsAt: { lte: now }, endsAt: { gte: now } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return this.markRead(
|
||||
notifications.map((notification) => notification.id),
|
||||
accessToken,
|
||||
);
|
||||
}
|
||||
|
||||
private async getAuthUserId(accessToken: string): Promise<string> {
|
||||
const response = await fetch(`${this.authServerUrl}/auth/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new UnauthorizedException('Invalid bearer token');
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as AuthMeResponse | null;
|
||||
if (!payload?.user?.id) throw new UnauthorizedException('Invalid bearer token');
|
||||
|
||||
return payload.user.id;
|
||||
}
|
||||
}
|
||||
|
||||
function formatNotificationDate(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
|
||||
return `${year}.${month}.${day}`;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ProvisioningDeviceDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
hostname?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
os?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
arch?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
kernel?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
distro?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
usageType?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
sshUser?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
workspaceRoot?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
publicSshKey?: string;
|
||||
}
|
||||
|
||||
export class CreateProvisioningDto {
|
||||
@IsEmail()
|
||||
account: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
hostname: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
os?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
arch?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
kernel?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
distro?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
usageType?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
sshUser?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
workspaceRoot?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
publicSshKey?: string;
|
||||
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => ProvisioningDeviceDto)
|
||||
@IsOptional()
|
||||
device?: ProvisioningDeviceDto;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Body, Controller, Headers, Post, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { CreateProvisioningDto } from './dto/create-provisioning.dto';
|
||||
import { ProvisioningService } from './provisioning.service';
|
||||
import type { ProvisioningResponse } from './provisioning.service';
|
||||
|
||||
@Controller('api/provision')
|
||||
export class ProvisioningController {
|
||||
constructor(
|
||||
private readonly provisioningService: ProvisioningService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
createProvisioning(
|
||||
@Body() createProvisioningDto: CreateProvisioningDto,
|
||||
): Promise<ProvisioningResponse> {
|
||||
return this.provisioningService.createProvisioning(createProvisioningDto);
|
||||
}
|
||||
|
||||
@Post('ssh-keys/lookup')
|
||||
listSshKeys(
|
||||
@Headers('x-lms-gateway-token') token: string | undefined,
|
||||
@Body() body: { emails?: string[] },
|
||||
) {
|
||||
const expectedToken =
|
||||
this.configService.get<string>('LMS_GATEWAY_TOKEN') ??
|
||||
this.configService.get<string>('PLATFORM_GATEWAY_TOKEN') ??
|
||||
'';
|
||||
if (!expectedToken || token !== expectedToken) {
|
||||
throw new UnauthorizedException('invalid_gateway_token');
|
||||
}
|
||||
return this.provisioningService.listLauncherSshKeys(body.emails ?? []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { ProvisioningController } from './provisioning.controller';
|
||||
import { ProvisioningService } from './provisioning.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [ProvisioningController],
|
||||
providers: [ProvisioningService],
|
||||
})
|
||||
export class ProvisioningModule {}
|
||||
@@ -0,0 +1,622 @@
|
||||
import {
|
||||
BadGatewayException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateProvisioningDto } from './dto/create-provisioning.dto';
|
||||
|
||||
export type ProvisioningResponse = {
|
||||
installTailscale: boolean;
|
||||
tailscaleAuthKey: string;
|
||||
tailscaleLoginServer: string;
|
||||
tailscaleHostname: string;
|
||||
tailscaleExtraArgs: string;
|
||||
provisioningMode: 'personal' | 'class';
|
||||
agentGatewayToken: string;
|
||||
};
|
||||
|
||||
type AuthLoginResponse = {
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string | null;
|
||||
role?: string;
|
||||
roles?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ProvisioningService {
|
||||
private readonly authServerUrl: string;
|
||||
private readonly headscaleBaseUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {
|
||||
this.authServerUrl = this.configService
|
||||
.get<string>('AUTH_SERVER_URL', 'http://localhost:4100')
|
||||
.replace(/\/$/, '');
|
||||
this.headscaleBaseUrl = this.configService
|
||||
.get<string>(
|
||||
'HEADSCALE_BASE_URL',
|
||||
this.configService.get<string>(
|
||||
'TAILSCALE_LOGIN_SERVER',
|
||||
'https://atnmy-hs.duckdns.org',
|
||||
),
|
||||
)
|
||||
.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
async createProvisioning(
|
||||
provisioningDto: CreateProvisioningDto,
|
||||
): Promise<ProvisioningResponse> {
|
||||
const authUser = await this.authenticateAccount(
|
||||
provisioningDto.account,
|
||||
provisioningDto.password,
|
||||
);
|
||||
const headscaleUser = this.toHeadscaleUserName(authUser.email);
|
||||
const provisioningMode = this.normalizeProvisioningMode(
|
||||
provisioningDto.usageType ?? provisioningDto.device?.usageType,
|
||||
);
|
||||
if (provisioningMode === 'class' && !this.isInstructor(authUser)) {
|
||||
throw new ForbiddenException('Instructor role required for class device');
|
||||
}
|
||||
const aclTags =
|
||||
provisioningMode === 'class' ? this.getClassDeviceAclTags() : [];
|
||||
const tailscaleHostname = this.toTailscaleHostname(
|
||||
provisioningDto.hostname || 'agent',
|
||||
headscaleUser,
|
||||
);
|
||||
await this.recordProvisionedDevice(
|
||||
provisioningDto,
|
||||
authUser,
|
||||
tailscaleHostname,
|
||||
provisioningMode,
|
||||
);
|
||||
await this.recordLauncherSshKey(provisioningDto, authUser);
|
||||
|
||||
return {
|
||||
installTailscale: true,
|
||||
tailscaleAuthKey: await this.createTailscaleAuthKey(
|
||||
headscaleUser,
|
||||
aclTags,
|
||||
),
|
||||
tailscaleLoginServer: this.configService.get<string>(
|
||||
'TAILSCALE_LOGIN_SERVER',
|
||||
this.headscaleBaseUrl,
|
||||
),
|
||||
tailscaleHostname,
|
||||
tailscaleExtraArgs: this.configService.get<string>(
|
||||
'TAILSCALE_EXTRA_ARGS',
|
||||
'--accept-routes',
|
||||
),
|
||||
provisioningMode,
|
||||
agentGatewayToken: this.configService.get<string>(
|
||||
'AGENT_GATEWAY_TOKEN',
|
||||
'',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async listLauncherSshKeys(emails: string[]) {
|
||||
const normalizedEmails = Array.from(
|
||||
new Set(
|
||||
emails
|
||||
.map((email) => email.trim().toLowerCase())
|
||||
.filter((email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)),
|
||||
),
|
||||
);
|
||||
if (!normalizedEmails.length) return { keysByEmail: {} };
|
||||
|
||||
const keys = await this.prisma.launcherSshKey.findMany({
|
||||
where: { accountEmail: { in: normalizedEmails } },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
select: {
|
||||
accountEmail: true,
|
||||
publicKey: true,
|
||||
keyType: true,
|
||||
label: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const keysByEmail: Record<string, Array<{
|
||||
publicKey: string;
|
||||
keyType: string | null;
|
||||
label: string | null;
|
||||
updatedAt: string;
|
||||
}>> = {};
|
||||
for (const key of keys) {
|
||||
const bucket = keysByEmail[key.accountEmail] ?? [];
|
||||
bucket.push({
|
||||
publicKey: key.publicKey,
|
||||
keyType: key.keyType,
|
||||
label: key.label,
|
||||
updatedAt: key.updatedAt.toISOString(),
|
||||
});
|
||||
keysByEmail[key.accountEmail] = bucket;
|
||||
}
|
||||
return { keysByEmail };
|
||||
}
|
||||
|
||||
private async recordLauncherSshKey(
|
||||
provisioningDto: CreateProvisioningDto,
|
||||
authUser: NonNullable<AuthLoginResponse['user']>,
|
||||
) {
|
||||
const publicKey = this.normalizePublicSshKey(
|
||||
provisioningDto.publicSshKey || provisioningDto.device?.publicSshKey,
|
||||
);
|
||||
if (!publicKey) return;
|
||||
|
||||
const publicKeyHash = createHash('sha256').update(publicKey).digest('hex');
|
||||
const keyType = publicKey.split(/\s+/, 1)[0] || null;
|
||||
await this.prisma.launcherSshKey.upsert({
|
||||
where: {
|
||||
accountEmail_publicKeyHash: {
|
||||
accountEmail: authUser.email,
|
||||
publicKeyHash,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
accountEmail: authUser.email,
|
||||
authUserId: authUser.id,
|
||||
publicKey,
|
||||
publicKeyHash,
|
||||
keyType,
|
||||
label: 'launcher',
|
||||
lastProvisionedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
authUserId: authUser.id,
|
||||
publicKey,
|
||||
keyType,
|
||||
label: 'launcher',
|
||||
lastProvisionedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async recordProvisionedDevice(
|
||||
provisioningDto: CreateProvisioningDto,
|
||||
authUser: NonNullable<AuthLoginResponse['user']>,
|
||||
tailscaleHostname: string,
|
||||
provisioningMode: 'personal' | 'class',
|
||||
) {
|
||||
const hostname = this.cleanString(
|
||||
provisioningDto.hostname || provisioningDto.device?.hostname || 'agent',
|
||||
) ?? 'agent';
|
||||
const sshUser = this.cleanString(
|
||||
provisioningDto.sshUser || provisioningDto.device?.sshUser,
|
||||
);
|
||||
const workspaceRoot = this.cleanString(
|
||||
provisioningDto.workspaceRoot || provisioningDto.device?.workspaceRoot,
|
||||
);
|
||||
const now = new Date();
|
||||
await this.prisma.provisionedDevice.upsert({
|
||||
where: {
|
||||
accountEmail_tailscaleHostname: {
|
||||
accountEmail: authUser.email,
|
||||
tailscaleHostname,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
accountEmail: authUser.email,
|
||||
authUserId: authUser.id,
|
||||
hostname,
|
||||
tailscaleHostname,
|
||||
sshUser,
|
||||
workspaceRoot,
|
||||
usageType: provisioningMode,
|
||||
os: this.cleanString(provisioningDto.os || provisioningDto.device?.os),
|
||||
arch: this.cleanString(
|
||||
provisioningDto.arch || provisioningDto.device?.arch,
|
||||
),
|
||||
kernel: this.cleanString(
|
||||
provisioningDto.kernel || provisioningDto.device?.kernel,
|
||||
),
|
||||
distro: this.cleanString(
|
||||
provisioningDto.distro || provisioningDto.device?.distro,
|
||||
),
|
||||
device: this.cleanJson(provisioningDto.device),
|
||||
installedAt: now,
|
||||
lastProvisionedAt: now,
|
||||
},
|
||||
update: {
|
||||
authUserId: authUser.id,
|
||||
hostname,
|
||||
sshUser,
|
||||
workspaceRoot,
|
||||
usageType: provisioningMode,
|
||||
os: this.cleanString(provisioningDto.os || provisioningDto.device?.os),
|
||||
arch: this.cleanString(
|
||||
provisioningDto.arch || provisioningDto.device?.arch,
|
||||
),
|
||||
kernel: this.cleanString(
|
||||
provisioningDto.kernel || provisioningDto.device?.kernel,
|
||||
),
|
||||
distro: this.cleanString(
|
||||
provisioningDto.distro || provisioningDto.device?.distro,
|
||||
),
|
||||
device: this.cleanJson(provisioningDto.device),
|
||||
lastProvisionedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private cleanString(value?: string | null) {
|
||||
const normalized = value?.trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
private normalizePublicSshKey(value?: string | null) {
|
||||
const normalized = value?.trim().replace(/\s+/g, ' ');
|
||||
if (!normalized) return null;
|
||||
if (!/^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private cleanJson(value: unknown): Prisma.InputJsonValue | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
private async authenticateAccount(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<NonNullable<AuthLoginResponse['user']>> {
|
||||
let response: globalThis.Response;
|
||||
try {
|
||||
response = await fetch(`${this.authServerUrl}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
} catch {
|
||||
throw new BadGatewayException('Auth server is unavailable');
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| AuthLoginResponse
|
||||
| null;
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new UnauthorizedException('Invalid account or password');
|
||||
}
|
||||
|
||||
if (!response.ok || !payload?.user?.email) {
|
||||
throw new BadGatewayException({
|
||||
message: 'Auth server request failed',
|
||||
statusCode: response.status,
|
||||
error: payload,
|
||||
});
|
||||
}
|
||||
|
||||
return payload.user;
|
||||
}
|
||||
|
||||
private async createTailscaleAuthKey(
|
||||
headscaleUser: string,
|
||||
aclTags: string[],
|
||||
): Promise<string> {
|
||||
const headscaleApiKey = this.configService.get<string>('HEADSCALE_API_KEY');
|
||||
if (!headscaleApiKey) {
|
||||
throw new BadGatewayException('HEADSCALE_API_KEY is not configured');
|
||||
}
|
||||
|
||||
const headscaleUserId = await this.ensureHeadscaleUser(
|
||||
headscaleUser,
|
||||
headscaleApiKey,
|
||||
);
|
||||
return this.createHeadscalePreAuthKey(
|
||||
headscaleUserId,
|
||||
headscaleApiKey,
|
||||
aclTags,
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureHeadscaleUser(
|
||||
userName: string,
|
||||
apiKey: string,
|
||||
): Promise<number> {
|
||||
const response = await this.headscaleRequest('/api/v1/user', apiKey, {
|
||||
method: 'POST',
|
||||
body: { name: userName },
|
||||
});
|
||||
const payload = await this.readResponsePayload(response);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 409 || this.isAlreadyExistsResponse(payload)) {
|
||||
return this.findHeadscaleUserId(userName, apiKey);
|
||||
}
|
||||
throw new BadGatewayException(
|
||||
this.formatHeadscaleError(
|
||||
'Headscale user provisioning failed',
|
||||
response.status,
|
||||
payload,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const createdUserId = this.findUserId(payload, userName);
|
||||
return createdUserId ?? this.findHeadscaleUserId(userName, apiKey);
|
||||
}
|
||||
|
||||
private async createHeadscalePreAuthKey(
|
||||
userId: number,
|
||||
apiKey: string,
|
||||
aclTags: string[],
|
||||
): Promise<string> {
|
||||
const expiresInSeconds = this.configService.get<number>(
|
||||
'TAILSCALE_PREAUTH_KEY_EXPIRATION_SECONDS',
|
||||
60 * 60 * 24,
|
||||
);
|
||||
const expiration = new Date(
|
||||
Date.now() + expiresInSeconds * 1000,
|
||||
).toISOString();
|
||||
|
||||
const { response, payload } = await this.createHeadscalePreAuthKeyRequest(
|
||||
userId,
|
||||
apiKey,
|
||||
expiration,
|
||||
aclTags,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (this.isAlreadyExistsResponse(payload)) {
|
||||
const retry = await this.createHeadscalePreAuthKeyRequest(
|
||||
userId,
|
||||
apiKey,
|
||||
new Date(Date.now() + (expiresInSeconds + 30) * 1000).toISOString(),
|
||||
aclTags,
|
||||
);
|
||||
if (retry.response.ok) {
|
||||
const retryKey = this.findAuthKey(retry.payload);
|
||||
if (retryKey) return retryKey;
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadGatewayException(
|
||||
this.formatHeadscaleError(
|
||||
'Headscale pre-auth key provisioning failed',
|
||||
response.status,
|
||||
payload,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const key = this.findAuthKey(payload);
|
||||
if (!key) {
|
||||
throw new BadGatewayException('Headscale response did not include a key');
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
private async createHeadscalePreAuthKeyRequest(
|
||||
userId: number,
|
||||
apiKey: string,
|
||||
expiration: string,
|
||||
aclTags: string[],
|
||||
) {
|
||||
const response = await this.headscaleRequest('/api/v1/preauthkey', apiKey, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
user: userId,
|
||||
reusable: false,
|
||||
ephemeral: false,
|
||||
expiration,
|
||||
aclTags,
|
||||
},
|
||||
});
|
||||
return {
|
||||
response,
|
||||
payload: await this.readResponsePayload(response),
|
||||
};
|
||||
}
|
||||
|
||||
private async headscaleRequest(
|
||||
path: string,
|
||||
apiKey: string,
|
||||
options: { method: 'GET' | 'POST'; body?: unknown },
|
||||
) {
|
||||
try {
|
||||
return await fetch(`${this.headscaleBaseUrl}${path}`, {
|
||||
method: options.method,
|
||||
headers: {
|
||||
authorization: `Bearer ${apiKey}`,
|
||||
...(options.body ? { 'content-type': 'application/json' } : {}),
|
||||
},
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
} catch {
|
||||
throw new BadGatewayException('Headscale server is unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
private async readResponsePayload(response: Response): Promise<unknown> {
|
||||
const text = await response.text().catch(() => '');
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
private formatHeadscaleError(
|
||||
message: string,
|
||||
statusCode: number,
|
||||
payload: unknown,
|
||||
): string {
|
||||
const detail = this.formatPayloadMessage(payload);
|
||||
return detail
|
||||
? `${message}: HTTP ${statusCode} ${detail}`
|
||||
: `${message}: HTTP ${statusCode}`;
|
||||
}
|
||||
|
||||
private formatPayloadMessage(payload: unknown): string {
|
||||
if (!payload) return '';
|
||||
if (typeof payload === 'string') return payload;
|
||||
if (Array.isArray(payload)) {
|
||||
return payload
|
||||
.map((item) => this.formatPayloadMessage(item))
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
}
|
||||
if (typeof payload !== 'object') return String(payload);
|
||||
|
||||
const record = payload as Record<string, unknown>;
|
||||
for (const key of ['message', 'error', 'details']) {
|
||||
const message = this.formatPayloadMessage(record[key]);
|
||||
if (message) return message;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(payload);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private async findHeadscaleUserId(
|
||||
userName: string,
|
||||
apiKey: string,
|
||||
): Promise<number> {
|
||||
const listResponse = await this.headscaleRequest('/api/v1/user', apiKey, {
|
||||
method: 'GET',
|
||||
});
|
||||
const listPayload = (await listResponse.json().catch(() => null)) as unknown;
|
||||
const listedUserId = this.findUserId(listPayload, userName);
|
||||
if (listedUserId) return listedUserId;
|
||||
|
||||
const detailResponse = await this.headscaleRequest(
|
||||
`/api/v1/user/${encodeURIComponent(userName)}`,
|
||||
apiKey,
|
||||
{ method: 'GET' },
|
||||
);
|
||||
const detailPayload = (await detailResponse
|
||||
.json()
|
||||
.catch(() => null)) as unknown;
|
||||
const detailUserId = this.findUserId(detailPayload, userName);
|
||||
if (detailUserId) return detailUserId;
|
||||
|
||||
throw new BadGatewayException({
|
||||
message: 'Headscale user id was not found',
|
||||
error: { user: userName },
|
||||
});
|
||||
}
|
||||
|
||||
private findAuthKey(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== 'object') return null;
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (typeof record.key === 'string') return record.key;
|
||||
|
||||
for (const value of Object.values(record)) {
|
||||
const nestedKey = this.findAuthKey(value);
|
||||
if (nestedKey) return nestedKey;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private isAlreadyExistsResponse(payload: unknown) {
|
||||
const serialized =
|
||||
typeof payload === 'string' ? payload : JSON.stringify(payload ?? {});
|
||||
return /already exists|exists|duplicate|unique constraint/i.test(
|
||||
serialized,
|
||||
);
|
||||
}
|
||||
|
||||
private findUserId(payload: unknown, userName: string): number | null {
|
||||
if (!payload || typeof payload !== 'object') return null;
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
for (const item of payload) {
|
||||
const userId = this.findUserId(item, userName);
|
||||
if (userId) return userId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = payload as Record<string, unknown>;
|
||||
const recordNames = ['name', 'username', 'email']
|
||||
.map((key) => (typeof record[key] === 'string' ? String(record[key]) : ''))
|
||||
.filter(Boolean);
|
||||
const normalizedRecordNames = recordNames.map((name) =>
|
||||
this.toHeadscaleUserName(name),
|
||||
);
|
||||
const normalizedUserName = this.toHeadscaleUserName(userName);
|
||||
|
||||
if (
|
||||
normalizedRecordNames.includes(normalizedUserName) &&
|
||||
(typeof record.id === 'number' || typeof record.id === 'string')
|
||||
) {
|
||||
const parsedId = Number(record.id);
|
||||
return Number.isFinite(parsedId) && parsedId > 0 ? parsedId : null;
|
||||
}
|
||||
|
||||
for (const value of Object.values(record)) {
|
||||
const userId = this.findUserId(value, userName);
|
||||
if (userId) return userId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private toHeadscaleUserName(email: string) {
|
||||
return email
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/@/g, '-at-')
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 63);
|
||||
}
|
||||
|
||||
private normalizeProvisioningMode(value?: string | null): 'personal' | 'class' {
|
||||
const normalized = String(value ?? '').trim().toLowerCase();
|
||||
return ['class', 'classroom', 'education', 'educational', 'training', 'use for class'].includes(normalized)
|
||||
? 'class'
|
||||
: 'personal';
|
||||
}
|
||||
|
||||
private isInstructor(user: NonNullable<AuthLoginResponse['user']>) {
|
||||
const roles = new Set(
|
||||
[user.role, ...(user.roles ?? [])]
|
||||
.filter(Boolean)
|
||||
.map((role) => String(role).toUpperCase()),
|
||||
);
|
||||
return ['INSTRUCTOR', 'LMS_ADMIN', 'ADMIN'].some((role) =>
|
||||
roles.has(role),
|
||||
);
|
||||
}
|
||||
|
||||
private getClassDeviceAclTags() {
|
||||
return this.configService
|
||||
.get<string>('HEADSCALE_CLASS_DEVICE_TAGS', '')
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
.map((tag) => (tag.startsWith('tag:') ? tag : `tag:${tag}`));
|
||||
}
|
||||
|
||||
private toTailscaleHostname(hostname: string, headscaleUser: string) {
|
||||
const normalizedHost = hostname
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
return `${headscaleUser}-${normalizedHost || 'agent'}`.slice(0, 63);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user