From 828782bf2293892e0ad6c7846c4beada900caaa3 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Wed, 26 Nov 2025 10:10:03 -0800 Subject: [PATCH] feat: add source download functionality to builds and profiles --- convex/builds.ts | 167 ++++++++++++++++++++++++++---------- src/pages/BuildProgress.tsx | 31 +++++++ src/pages/ProfileFlash.tsx | 26 +++++- 3 files changed, 180 insertions(+), 44 deletions(-) diff --git a/convex/builds.ts b/convex/builds.ts index ca1e34a..f8c4721 100644 --- a/convex/builds.ts +++ b/convex/builds.ts @@ -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, + buildId: Id<'builds'>, + profileId: Id<'profiles'>, + objectKey: string, + ext: string, + filenameSuffix: string = '', + contentType: string = 'application/octet-stream', + incrementFlashCount: boolean = true +): Promise { + 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 { + 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' ) }, }) diff --git a/src/pages/BuildProgress.tsx b/src/pages/BuildProgress.tsx index dbcb280..06aebca 100644 --- a/src/pages/BuildProgress.tsx +++ b/src/pages/BuildProgress.tsx @@ -18,7 +18,13 @@ export default function BuildProgress() { const generateDownloadUrl = useMutation( api.builds.generateAnonymousDownloadUrl ) + const generateSourceDownloadUrl = useMutation( + api.builds.generateAnonymousSourceDownloadUrl + ) const [downloadError, setDownloadError] = useState(null) + const [sourceDownloadError, setSourceDownloadError] = useState( + 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 @@ -176,6 +197,16 @@ export default function BuildProgress() { {downloadError && (

{downloadError}

)} + + {sourceDownloadError && ( +

{sourceDownloadError}

+ )} )} diff --git a/src/pages/ProfileFlash.tsx b/src/pages/ProfileFlash.tsx index 4e5b8be..052056d 100644 --- a/src/pages/ProfileFlash.tsx +++ b/src/pages/ProfileFlash.tsx @@ -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() { {build.status === 'success' && build.artifactPath && ( -
+
+
)}