Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"@ai-sdk/react": "^1.2.9",
"@aws-sdk/client-s3": "^3.806.0",
"@aws-sdk/client-sts": "^3.808.0",
"@aws-sdk/s3-request-presigner": "^3.806.0",
"@aws-sdk/s3-request-presigner": "^3.832.0",
"@azure/core-rest-pipeline": "^1.21.0",
"@browserbasehq/sdk": "^2.5.0",
"@calcom/atoms": "^1.0.102-framer",
Expand Down
3 changes: 3 additions & 0 deletions apps/portal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@
"name": "@comp/portal",
"version": "0.1.0",
"dependencies": {
"@aws-sdk/s3-request-presigner": "^3.832.0",
"@comp/db": "workspace:*",
"@comp/ui": "workspace:*",
"@react-email/components": "^0.0.41",
"@react-email/render": "^1.1.2",
"@t3-oss/env-nextjs": "^0.13.8",
"@types/jszip": "^3.4.1",
"archiver": "^7.0.1",
"better-auth": "^1.2.8",
"class-variance-authority": "^0.7.1",
"jszip": "^3.10.1",
"next": "15.4.0-canary.85",
"react-email": "^4.0.15"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@c
import { Button } from '@comp/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card';
import { cn } from '@comp/ui/cn';
import { CheckCircle2, Circle, Download, XCircle } from 'lucide-react';
import { CheckCircle2, Circle, Download, Loader2, XCircle } from 'lucide-react';
import Image from 'next/image';
import { useState } from 'react';
import { toast } from 'sonner';
Expand All @@ -31,8 +31,10 @@ export function DeviceAgentAccordionItem({

const handleDownload = async () => {
setIsDownloading(true);

try {
const response = await fetch('/api/download-agent', {
// First, we need to get a download token/session from the API
const tokenResponse = await fetch('/api/download-agent/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Expand All @@ -41,25 +43,52 @@ export function DeviceAgentAccordionItem({
}),
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to download agent.');
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
throw new Error(errorText || 'Failed to prepare download.');
}

const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const { token } = await tokenResponse.json();

// Now trigger the actual download using the browser's native download mechanism
// This will show in the browser's download UI immediately
const downloadUrl = `/api/download-agent?token=${encodeURIComponent(token)}`;

// Method 1: Using a temporary link (most reliable)
const a = document.createElement('a');
a.href = url;
a.href = downloadUrl;
a.download = 'compai-device-agent.zip';
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);

toast.success('Download started! Check your downloads folder.');
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'An unknown error occurred.');
toast.error(error instanceof Error ? error.message : 'Failed to download agent.');
} finally {
setIsDownloading(false);
// Reset after a short delay to allow download to start
setTimeout(() => {
setIsDownloading(false);
}, 1000);
}
};

const getButtonContent = () => {
if (isDownloading) {
return (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Downloading...
</>
);
} else {
return (
<>
<Download className="h-4 w-4" />
Download Agent
</>
);
}
};

Expand Down Expand Up @@ -104,8 +133,7 @@ export function DeviceAgentAccordionItem({
disabled={isDownloading || hasInstalledAgent}
className="gap-2 mt-2"
>
<Download className="h-4 w-4" />
{isDownloading ? 'Downloading...' : 'Download Agent'}
{getButtonContent()}
</Button>
</li>
<li>
Expand Down
183 changes: 157 additions & 26 deletions apps/portal/src/app/api/download-agent/route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,117 @@
import { auth } from '@/app/lib/auth';
import { logger } from '@/utils/logger';
import { s3Client } from '@/utils/s3';
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { client as kv } from '@comp/kv';
import archiver from 'archiver';
import { type NextRequest, NextResponse } from 'next/server';
import { promises as fs } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { createAgentArchive } from './archive';
import { PassThrough, Readable } from 'stream';
import { createFleetLabel } from './fleet-label';
import { generateMacScript, generateWindowsScript } from './scripts';
import {
generateMacScript,
generateWindowsScript,
getPackageFilename,
getReadmeContent,
getScriptFilename,
} from './scripts';
import type { DownloadAgentRequest, SupportedOS } from './types';
import { detectOSFromUserAgent, validateMemberAndOrg } from './utils';

// GET handler for direct browser downloads using token
export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams;
const token = searchParams.get('token');

if (!token) {
return new NextResponse('Missing download token', { status: 400 });
}

// Retrieve download info from KV store
const downloadInfo = await kv.get(`download:${token}`);

if (!downloadInfo) {
return new NextResponse('Invalid or expired download token', { status: 403 });
}

// Delete token after retrieval (one-time use)
await kv.del(`download:${token}`);

const { orgId, employeeId, os } = downloadInfo as {
orgId: string;
employeeId: string;
userId: string;
os: 'macos' | 'windows';
};

// Check environment configuration
const fleetDevicePathMac = process.env.FLEET_DEVICE_PATH_MAC;
const fleetDevicePathWindows = process.env.FLEET_DEVICE_PATH_WINDOWS;
const fleetBucketName = process.env.FLEET_AGENT_BUCKET_NAME;

if (!fleetDevicePathMac || !fleetDevicePathWindows || !fleetBucketName) {
return new NextResponse('Server configuration error', { status: 500 });
}

// Generate OS-specific script
const fleetDevicePath = os === 'macos' ? fleetDevicePathMac : fleetDevicePathWindows;
const script =
os === 'macos'
? generateMacScript({ orgId, employeeId, fleetDevicePath })
: generateWindowsScript({ orgId, employeeId, fleetDevicePath });

try {
// Create a passthrough stream for the response
const passThrough = new PassThrough();
const archive = archiver('zip', { zlib: { level: 9 } });

// Pipe archive to passthrough
archive.pipe(passThrough);

// Add script file
const scriptFilename = getScriptFilename(os);
archive.append(script, { name: scriptFilename, mode: 0o755 });

// Add README
const readmeContent = getReadmeContent(os);
archive.append(readmeContent, { name: 'README.txt' });

// Get package from S3 and stream it
const packageFilename = getPackageFilename(os);
const packageKey = `${os}/fleet-osquery.${os === 'macos' ? 'pkg' : 'msi'}`;

const getObjectCommand = new GetObjectCommand({
Bucket: fleetBucketName,
Key: packageKey,
});

const s3Response = await s3Client.send(getObjectCommand);

if (s3Response.Body) {
const s3Stream = s3Response.Body as Readable;
archive.append(s3Stream, { name: packageFilename, store: true });
}

// Finalize the archive
archive.finalize();

// Convert Node.js stream to Web Stream for NextResponse
const webStream = Readable.toWeb(passThrough) as unknown as ReadableStream;

// Return streaming response with headers that trigger browser download
return new NextResponse(webStream, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="compai-device-agent-${os}.zip"`,
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
});
} catch (error) {
logger('Error creating agent download', { error });
return new NextResponse('Failed to create download', { status: 500 });
}
}

// POST handler remains the same for backward compatibility or direct API usage
export async function POST(req: NextRequest) {
// Authentication
const session = await auth.api.getSession({
Expand Down Expand Up @@ -44,6 +146,7 @@ export async function POST(req: NextRequest) {
// Check environment configuration
const fleetDevicePathMac = process.env.FLEET_DEVICE_PATH_MAC;
const fleetDevicePathWindows = process.env.FLEET_DEVICE_PATH_WINDOWS;
const fleetBucketName = process.env.FLEET_AGENT_BUCKET_NAME;

if (!fleetDevicePathMac || !fleetDevicePathWindows) {
logger(
Expand All @@ -57,6 +160,12 @@ export async function POST(req: NextRequest) {
);
}

if (!fleetBucketName) {
return new NextResponse('Server configuration error: Fleet bucket name is missing.', {
status: 500,
});
}

// Validate member and organization
const member = await validateMemberAndOrg(session.user.id, orgId);
if (!member) {
Expand All @@ -70,18 +179,7 @@ export async function POST(req: NextRequest) {
? generateMacScript({ orgId, employeeId, fleetDevicePath })
: generateWindowsScript({ orgId, employeeId, fleetDevicePath });

// Create temporary directory
const tempDir = path.join(tmpdir(), `compai-agent-${Date.now()}`);
await fs.mkdir(tempDir, { recursive: true });

try {
// Create the archive
const stream = await createAgentArchive({
os: os as SupportedOS,
script,
tempDir,
});

// Create Fleet label
await createFleetLabel({
employeeId,
Expand All @@ -91,20 +189,53 @@ export async function POST(req: NextRequest) {
fleetDevicePathWindows,
});

const filename = `compai-device-agent-${os}.zip`;
// Create a passthrough stream for the response
const passThrough = new PassThrough();
const archive = archiver('zip', { zlib: { level: 9 } });

// Pipe archive to passthrough
archive.pipe(passThrough);

return new NextResponse(stream as unknown as ReadableStream, {
// Add script file
const scriptFilename = getScriptFilename(os);
archive.append(script, { name: scriptFilename, mode: 0o755 });

// Add README
const readmeContent = getReadmeContent(os);
archive.append(readmeContent, { name: 'README.txt' });

// Get package from S3 and stream it
const packageFilename = getPackageFilename(os);
const packageKey = `${os}/fleet-osquery.${os === 'macos' ? 'pkg' : 'msi'}`;

const getObjectCommand = new GetObjectCommand({
Bucket: fleetBucketName,
Key: packageKey,
});

const s3Response = await s3Client.send(getObjectCommand);

if (s3Response.Body) {
const s3Stream = s3Response.Body as Readable;
archive.append(s3Stream, { name: packageFilename, store: true });
}

// Finalize the archive
archive.finalize();

// Convert Node.js stream to Web Stream for NextResponse
const webStream = Readable.toWeb(passThrough) as unknown as ReadableStream;

// Return streaming response
return new NextResponse(webStream, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Disposition': `attachment; filename="compai-device-agent-${os}.zip"`,
'Cache-Control': 'no-cache',
},
});
} finally {
// Clean up temp directory
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch (cleanupError) {
logger('Failed to clean up temp directory', { error: cleanupError, tempDir });
}
} catch (error) {
logger('Error creating agent download', { error });
return new NextResponse('Failed to create download', { status: 500 });
}
}
Loading
Loading