feat: add source download functionality to builds and profiles

This commit is contained in:
Ben Allfree
2025-11-26 10:10:03 -08:00
parent 306be69033
commit 828782bf22
3 changed files with 180 additions and 44 deletions

View File

@@ -1,8 +1,9 @@
import { getAuthUserId } from '@convex-dev/auth/server'
import type { GenericMutationCtx } from 'convex/server'
import { v } from 'convex/values'
import { pick } from 'convex-helpers'
import { api, internal } from './_generated/api'
import type { Id } from './_generated/dataModel'
import type { DataModel, Id } from './_generated/dataModel'
import { internalMutation, mutation, query } from './_generated/server'
import { generateSignedDownloadUrl } from './lib/r2'
import { type BuildConfigFields, type BuildFields, buildFields } from './schema'
@@ -263,47 +264,91 @@ export const updateBuildStatus = internalMutation({
},
})
/**
* Helper to generate authenticated download URL
*/
async function generateAuthenticatedDownloadUrl(
ctx: GenericMutationCtx<DataModel>,
buildId: Id<'builds'>,
profileId: Id<'profiles'>,
objectKey: string,
ext: string,
filenameSuffix: string = '',
contentType: string = 'application/octet-stream',
incrementFlashCount: boolean = true
): Promise<string> {
const userId = await getAuthUserId(ctx)
if (!userId) throw new Error('Unauthorized')
// Verify profile belongs to user or is public
const profile = await ctx.db.get(profileId)
if (!profile) throw new Error('Profile not found')
// If profile is private, ensure user owns it
if (profile.isPublic === false && profile.userId !== userId) {
throw new Error('Unauthorized')
}
const build = await ctx.db.get(buildId)
if (!build) throw new Error('Build not found')
// Increment flash count for firmware downloads
if (incrementFlashCount) {
const nextCount = (profile.flashCount ?? 0) + 1
await ctx.db.patch(profileId, {
flashCount: nextCount,
updatedAt: Date.now(),
})
}
// Slugify profile name for filename
const slug = profile.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)+/g, '')
const filename = `${slug}-${build.config.target}${filenameSuffix}.${ext}`
return await generateSignedDownloadUrl(objectKey, filename, contentType)
}
/**
* Helper to generate anonymous download URL
*/
function generateAnonymousDownloadUrlHelper(
build: BuildFields,
slug: string,
objectKey: string,
ext: string,
filenameSuffix: string = '',
contentType: string = 'application/octet-stream'
): Promise<string> {
const {
buildHash,
config: { target, version },
} = build
const pfx = slug ? `${slug}-` : ''
const filename = `${pfx}${target}-${version}-${buildHash.substring(0, 4)}${filenameSuffix}.${ext}`
return generateSignedDownloadUrl(objectKey, filename, contentType)
}
export const generateDownloadUrl = mutation({
args: {
buildId: v.id('builds'),
profileId: v.id('profiles'),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx)
if (!userId) throw new Error('Unauthorized')
// Verify profile belongs to user or is public
const profile = await ctx.db.get(args.profileId)
if (!profile) throw new Error('Profile not found')
// If profile is private, ensure user owns it
if (profile.isPublic === false && profile.userId !== userId) {
throw new Error('Unauthorized')
}
const build = await ctx.db.get(args.buildId)
if (!build) throw new Error('Build not found')
// Increment flash count
const nextCount = (profile.flashCount ?? 0) + 1
await ctx.db.patch(args.profileId, {
flashCount: nextCount,
updatedAt: Date.now(),
})
// Slugify profile name for filename
const slug = profile.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)+/g, '')
// User indicated that artifactPath must be present for a valid download
if (!build.artifactPath) {
throw new Error('Build artifact path is missing')
}
let objectKey = build.artifactPath
// Remove leading slash if present
if (objectKey.startsWith('/')) {
objectKey = objectKey.substring(1)
@@ -317,12 +362,12 @@ export const generateDownloadUrl = mutation({
throw new Error('Could not determine file extension from artifact path')
}
const filename = `${slug}-${build.config.target}.${ext}`
return await generateSignedDownloadUrl(
return await generateAuthenticatedDownloadUrl(
ctx,
args.buildId,
args.profileId,
objectKey,
filename,
'application/octet-stream'
ext
)
},
})
@@ -344,18 +389,54 @@ export const generateAnonymousDownloadUrl = mutation({
throw new Error('Could not determine file extension from artifact path')
}
const {
buildHash,
config: { target, version },
} = args.build
const pfx = args.slug ? `${args.slug}-` : ''
const filename = `${pfx}${target}-${version}-${buildHash.substring(0, 4)}.${ext}`
return await generateSignedDownloadUrl(
return await generateAnonymousDownloadUrlHelper(
args.build,
args.slug,
objectKey,
filename,
'application/octet-stream'
ext
)
},
})
export const generateSourceDownloadUrl = mutation({
args: {
buildId: v.id('builds'),
profileId: v.id('profiles'),
},
handler: async (ctx, args) => {
const build = await ctx.db.get(args.buildId)
if (!build) throw new Error('Build not found')
const objectKey = `${build.buildHash}.tar.gz`
return await generateAuthenticatedDownloadUrl(
ctx,
args.buildId,
args.profileId,
objectKey,
'tar.gz',
'-source',
'application/gzip',
false // Don't increment flash count for source downloads
)
},
})
export const generateAnonymousSourceDownloadUrl = mutation({
args: {
build: v.object(buildFields),
slug: v.string(),
},
handler: async (_ctx, args) => {
const objectKey = `${args.build.buildHash}.tar.gz`
return await generateAnonymousDownloadUrlHelper(
args.build,
args.slug,
objectKey,
'tar.gz',
'-source',
'application/gzip'
)
},
})

View File

@@ -18,7 +18,13 @@ export default function BuildProgress() {
const generateDownloadUrl = useMutation(
api.builds.generateAnonymousDownloadUrl
)
const generateSourceDownloadUrl = useMutation(
api.builds.generateAnonymousSourceDownloadUrl
)
const [downloadError, setDownloadError] = useState<string | null>(null)
const [sourceDownloadError, setSourceDownloadError] = useState<string | null>(
null
)
if (!buildHash) {
return (
@@ -87,6 +93,21 @@ export default function BuildProgress() {
}
}
const handleSourceDownload = async () => {
setSourceDownloadError(null)
try {
const url = await generateSourceDownloadUrl({
build: pick(build, Object.keys(buildFields) as (keyof BuildFields)[]),
slug: `quick-build`,
})
window.location.href = url
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
setSourceDownloadError('Failed to generate source download link.')
console.error('Source download error', message)
}
}
const getStatusIcon = () => {
if (status === 'success') {
return <CheckCircle className="w-6 h-6 text-green-500" />
@@ -176,6 +197,16 @@ export default function BuildProgress() {
{downloadError && (
<p className="text-sm text-red-400">{downloadError}</p>
)}
<Button
onClick={handleSourceDownload}
className="w-full bg-slate-700 hover:bg-slate-600"
variant="outline"
>
Download source
</Button>
{sourceDownloadError && (
<p className="text-sm text-red-400">{sourceDownloadError}</p>
)}
</div>
)}

View File

@@ -30,6 +30,9 @@ export default function ProfileFlash() {
id ? { id: id as Id<'profiles'> } : 'skip'
)
const generateDownloadUrl = useMutation(api.builds.generateDownloadUrl)
const generateSourceDownloadUrl = useMutation(
api.builds.generateSourceDownloadUrl
)
useEffect(() => {
if (id && target && profile) {
@@ -104,6 +107,20 @@ export default function ProfileFlash() {
}
}
const handleSourceDownload = async () => {
if (!id) return
try {
const url = await generateSourceDownloadUrl({
buildId: build._id,
profileId: id as Id<'profiles'>,
})
window.location.href = url
} catch (error) {
console.error('Failed to generate source download URL', error)
}
}
const getStatusColor = (status: string) => {
if (status === 'success') return 'text-green-400'
if (status === 'failure') return 'text-red-400'
@@ -216,13 +233,20 @@ export default function ProfileFlash() {
</div>
{build.status === 'success' && build.artifactPath && (
<div>
<div className="space-y-2">
<Button
onClick={handleDownload}
className="bg-cyan-600 hover:bg-cyan-700 w-full"
>
Download Firmware
</Button>
<Button
onClick={handleSourceDownload}
className="bg-slate-700 hover:bg-slate-600 w-full"
variant="outline"
>
Download Source
</Button>
</div>
)}
</div>