|
1 | 1 | import { logger } from '@/utils/logger'; |
2 | 2 | import { s3Client } from '@/utils/s3'; |
3 | | -import { GetObjectCommand } from '@aws-sdk/client-s3'; |
| 3 | +import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; |
4 | 4 | import { client as kv } from '@comp/kv'; |
5 | 5 | import { type NextRequest, NextResponse } from 'next/server'; |
6 | 6 | import { Readable } from 'stream'; |
7 | 7 |
|
| 8 | +import { MAC_APPLE_SILICON_FILENAME, MAC_INTEL_FILENAME, WINDOWS_FILENAME } from './constants'; |
| 9 | +import type { SupportedOS } from './types'; |
| 10 | + |
8 | 11 | export const runtime = 'nodejs'; |
9 | 12 | export const dynamic = 'force-dynamic'; |
10 | 13 | export const maxDuration = 60; |
11 | 14 |
|
12 | | -// GET handler for direct browser downloads using token |
13 | | -export async function GET(req: NextRequest) { |
14 | | - const searchParams = req.nextUrl.searchParams; |
15 | | - const token = searchParams.get('token'); |
16 | | - const os = searchParams.get('os'); |
| 15 | +interface DownloadTokenInfo { |
| 16 | + orgId: string; |
| 17 | + employeeId: string; |
| 18 | + userId: string; |
| 19 | + os: SupportedOS; |
| 20 | + createdAt: number; |
| 21 | +} |
| 22 | + |
| 23 | +interface DownloadTarget { |
| 24 | + key: string; |
| 25 | + filename: string; |
| 26 | + contentType: string; |
| 27 | +} |
| 28 | + |
| 29 | +const getDownloadTarget = (os: SupportedOS): DownloadTarget => { |
| 30 | + if (os === 'windows') { |
| 31 | + return { |
| 32 | + key: `windows/${WINDOWS_FILENAME}`, |
| 33 | + filename: WINDOWS_FILENAME, |
| 34 | + contentType: 'application/octet-stream', |
| 35 | + }; |
| 36 | + } |
17 | 37 |
|
18 | | - if (!os) { |
19 | | - return new NextResponse('Missing OS', { status: 400 }); |
| 38 | + const isAppleSilicon = os === 'macos'; |
| 39 | + const filename = isAppleSilicon ? MAC_APPLE_SILICON_FILENAME : MAC_INTEL_FILENAME; |
| 40 | + |
| 41 | + return { |
| 42 | + key: `macos/${filename}`, |
| 43 | + filename, |
| 44 | + contentType: 'application/x-apple-diskimage', |
| 45 | + }; |
| 46 | +}; |
| 47 | + |
| 48 | +const buildResponseHeaders = ( |
| 49 | + target: DownloadTarget, |
| 50 | + contentLength?: number | null, |
| 51 | +): Record<string, string> => { |
| 52 | + const headers: Record<string, string> = { |
| 53 | + 'Content-Type': target.contentType, |
| 54 | + 'Content-Disposition': `attachment; filename="${target.filename}"`, |
| 55 | + 'Cache-Control': 'no-cache, no-store, must-revalidate', |
| 56 | + 'X-Accel-Buffering': 'no', |
| 57 | + }; |
| 58 | + |
| 59 | + if (typeof contentLength === 'number' && Number.isFinite(contentLength)) { |
| 60 | + headers['Content-Length'] = contentLength.toString(); |
20 | 61 | } |
21 | 62 |
|
| 63 | + return headers; |
| 64 | +}; |
| 65 | + |
| 66 | +const getDownloadToken = async (token: string): Promise<DownloadTokenInfo | null> => { |
| 67 | + const info = await kv.get<DownloadTokenInfo>(`download:${token}`); |
| 68 | + return info ?? null; |
| 69 | +}; |
| 70 | + |
| 71 | +const ensureBucket = (): string | null => { |
| 72 | + const bucket = process.env.FLEET_AGENT_BUCKET_NAME; |
| 73 | + return bucket ?? null; |
| 74 | +}; |
| 75 | + |
| 76 | +const handleDownload = async (req: NextRequest, isHead: boolean) => { |
| 77 | + const token = req.nextUrl.searchParams.get('token'); |
| 78 | + |
22 | 79 | if (!token) { |
23 | 80 | return new NextResponse('Missing download token', { status: 400 }); |
24 | 81 | } |
25 | 82 |
|
26 | | - // Retrieve download info from KV store |
27 | | - const downloadInfo = await kv.get(`download:${token}`); |
| 83 | + const downloadInfo = await getDownloadToken(token); |
28 | 84 |
|
29 | 85 | if (!downloadInfo) { |
30 | 86 | return new NextResponse('Invalid or expired download token', { status: 403 }); |
31 | 87 | } |
32 | 88 |
|
33 | | - // Delete token after retrieval (one-time use) |
34 | | - await kv.del(`download:${token}`); |
35 | | - |
36 | | - // Hardcoded device marker paths used by the setup scripts |
37 | | - const fleetBucketName = process.env.FLEET_AGENT_BUCKET_NAME; |
| 89 | + const fleetBucketName = ensureBucket(); |
38 | 90 |
|
39 | 91 | if (!fleetBucketName) { |
| 92 | + logger('Device agent download misconfigured: missing bucket'); |
40 | 93 | return new NextResponse('Server configuration error', { status: 500 }); |
41 | 94 | } |
42 | 95 |
|
43 | | - // For macOS, serve the DMG directly. For Windows, create a zip with script and installer. |
44 | | - if (os === 'macos' || os === 'macos-intel') { |
45 | | - try { |
46 | | - // Direct DMG download for macOS |
47 | | - const macosPackageFilename = |
48 | | - os === 'macos' ? 'Comp AI Agent-1.0.0-arm64.dmg' : 'Comp AI Agent-1.0.0.dmg'; |
49 | | - const packageKey = `macos/${macosPackageFilename}`; |
| 96 | + const target = getDownloadTarget(downloadInfo.os); |
50 | 97 |
|
51 | | - const getObjectCommand = new GetObjectCommand({ |
| 98 | + try { |
| 99 | + if (isHead) { |
| 100 | + const headCommand = new HeadObjectCommand({ |
52 | 101 | Bucket: fleetBucketName, |
53 | | - Key: packageKey, |
| 102 | + Key: target.key, |
54 | 103 | }); |
55 | 104 |
|
56 | | - const s3Response = await s3Client.send(getObjectCommand); |
| 105 | + const headResult = await s3Client.send(headCommand); |
57 | 106 |
|
58 | | - if (!s3Response.Body) { |
59 | | - return new NextResponse('DMG file not found', { status: 404 }); |
60 | | - } |
61 | | - |
62 | | - // Convert S3 stream to Web Stream for NextResponse |
63 | | - const s3Stream = s3Response.Body as Readable; |
64 | | - const webStream = Readable.toWeb(s3Stream) as unknown as ReadableStream; |
65 | | - |
66 | | - // Return streaming response with headers that trigger browser download |
67 | | - return new NextResponse(webStream, { |
68 | | - headers: { |
69 | | - 'Content-Type': 'application/x-apple-diskimage', |
70 | | - 'Content-Disposition': `attachment; filename="${macosPackageFilename}"`, |
71 | | - 'Cache-Control': 'no-cache, no-store, must-revalidate', |
72 | | - 'X-Accel-Buffering': 'no', |
73 | | - }, |
| 107 | + return new NextResponse(null, { |
| 108 | + headers: buildResponseHeaders(target, headResult.ContentLength ?? null), |
74 | 109 | }); |
75 | | - } catch (error) { |
76 | | - logger('Error downloading macOS DMG', { error }); |
77 | | - return new NextResponse('Failed to download macOS agent', { status: 500 }); |
78 | 110 | } |
79 | | - } |
80 | | - |
81 | | - // Windows flow: Generate script and create zip const fleetDevicePath = fleetDevicePathWindows; |
82 | | - try { |
83 | | - const windowsPackageFilename = 'Comp AI Agent 1.0.0.exe'; |
84 | | - const packageKey = `windows/${windowsPackageFilename}`; |
85 | 111 |
|
86 | 112 | const getObjectCommand = new GetObjectCommand({ |
87 | 113 | Bucket: fleetBucketName, |
88 | | - Key: packageKey, |
| 114 | + Key: target.key, |
89 | 115 | }); |
90 | 116 |
|
91 | 117 | const s3Response = await s3Client.send(getObjectCommand); |
92 | 118 |
|
93 | 119 | if (!s3Response.Body) { |
94 | | - return new NextResponse('Executable file not found', { status: 404 }); |
| 120 | + return new NextResponse('Installer file not found', { status: 404 }); |
95 | 121 | } |
96 | 122 |
|
97 | | - // Convert S3 stream to Web Stream for NextResponse |
| 123 | + await kv.del(`download:${token}`); |
| 124 | + |
98 | 125 | const s3Stream = s3Response.Body as Readable; |
99 | 126 | const webStream = Readable.toWeb(s3Stream) as unknown as ReadableStream; |
100 | 127 |
|
101 | | - // Return streaming response with headers that trigger browser download |
102 | 128 | return new NextResponse(webStream, { |
103 | | - headers: { |
104 | | - 'Content-Type': 'application/octet-stream', |
105 | | - 'Content-Disposition': `attachment; filename="${windowsPackageFilename}"`, |
106 | | - 'Cache-Control': 'no-cache, no-store, must-revalidate', |
107 | | - 'X-Accel-Buffering': 'no', |
108 | | - }, |
| 129 | + headers: buildResponseHeaders(target, s3Response.ContentLength ?? null), |
109 | 130 | }); |
110 | 131 | } catch (error) { |
111 | | - logger('Error creating agent download', { error }); |
112 | | - return new NextResponse('Failed to create download', { status: 500 }); |
| 132 | + logger('Error serving device agent download', { |
| 133 | + error, |
| 134 | + token, |
| 135 | + os: downloadInfo.os, |
| 136 | + method: isHead ? 'HEAD' : 'GET', |
| 137 | + }); |
| 138 | + |
| 139 | + return new NextResponse('Failed to download agent', { status: 500 }); |
113 | 140 | } |
| 141 | +}; |
| 142 | + |
| 143 | +export async function GET(req: NextRequest) { |
| 144 | + return handleDownload(req, false); |
| 145 | +} |
| 146 | + |
| 147 | +export async function HEAD(req: NextRequest) { |
| 148 | + return handleDownload(req, true); |
114 | 149 | } |
0 commit comments