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
10 changes: 10 additions & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Copy to .dev.vars for local Worker development only.
# Do not commit real secrets.

CONVEX_SITE_URL=https://your-deployment.convex.site
BLOOMSTUDIO_WORKER_SHARED_SECRET=replace-me
R2_PUBLIC_URL=https://your-public-r2-url.r2.dev
MEDIA_TRANSFORMS_BASE_URL=https://media.your-domain.com
CEREBRAS_API_KEY=replace-me
GROQ_API_KEY=replace-me
OPENROUTER_API_KEY=replace-me
19 changes: 19 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ jobs:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.6

- name: Configure Git
run: |
git config user.name "github-actions[bot]"
Expand Down Expand Up @@ -96,6 +101,20 @@ jobs:
git checkout -B release origin/main
git push origin release --force-with-lease

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Sync production worker secret
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
printf '%s' "${{ secrets.BLOOMSTUDIO_WORKER_SHARED_SECRET }}" | bunx wrangler secret put BLOOMSTUDIO_WORKER_SHARED_SECRET --env production

Comment thread
coderabbitai[bot] marked this conversation as resolved.
- name: Deploy production Cloudflare worker
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: bun run cf:deploy:prod

- name: Create and push tag
run: |
git tag -a ${{ steps.version.outputs.new_version }} -m "Release ${{ steps.version.outputs.new_version }}"
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,6 @@ next-env.d.ts
convex/_generated/
.DS_Store
.env*.local
.dev.vars
.wrangler
worker-configuration.d.ts
14 changes: 9 additions & 5 deletions app/api/images/delete-bulk/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { auth } from "@clerk/nextjs/server"
import { NextRequest, NextResponse } from "next/server"
import { deleteImages } from "@/lib/storage"
import crypto from "crypto"
import { z } from "zod"

interface DeleteBulkResponse {
success: true
Expand All @@ -26,6 +27,10 @@ interface DeleteBulkError {
}
}

const deleteBulkRequestSchema = z.object({
r2Keys: z.array(z.string().min(1)),
})

/**
* Hash a userId the same way it's hashed when generating R2 keys.
* This ensures authorization checks match the stored key format.
Expand All @@ -49,15 +54,16 @@ export async function POST(
)
}

const { r2Keys } = await request.json()

if (!r2Keys || !Array.isArray(r2Keys)) {
const parsed = deleteBulkRequestSchema.safeParse(await request.json())
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: { code: "INVALID_INPUT", message: "r2Keys must be an array" } },
{ status: 400 }
)
}

const { r2Keys } = parsed.data

if (r2Keys.length === 0) {
return NextResponse.json({
success: true,
Expand Down Expand Up @@ -89,8 +95,6 @@ export async function POST(
const unauthorizedKeys: string[] = []

for (const key of r2Keys) {
if (typeof key !== "string") continue

if (key.includes(`/${userHash}/`)) {
authorizedKeys.push(key)
} else {
Expand Down
101 changes: 101 additions & 0 deletions app/api/images/delete/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import crypto from "crypto"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { NextRequest } from "next/server"
import { POST } from "./route"

const { mockAuth, mockDeleteImage } = vi.hoisted(() => ({
mockAuth: vi.fn(),
mockDeleteImage: vi.fn(),
}))

vi.mock("@clerk/nextjs/server", () => ({
auth: mockAuth,
}))

vi.mock("@/lib/storage", () => ({
deleteImage: mockDeleteImage,
}))

function buildRequest(body: string): NextRequest {
return new NextRequest("http://localhost:3000/api/images/delete", {
method: "POST",
headers: { "content-type": "application/json" },
body,
})
}

describe("/api/images/delete", () => {
beforeEach(() => {
vi.clearAllMocks()
})

afterEach(() => {
vi.resetAllMocks()
})

it("returns 400 for malformed JSON payloads", async () => {
mockAuth.mockResolvedValue({ userId: "user_123" })

const response = await POST(buildRequest("{"))
const data = await response.json()

expect(response.status).toBe(400)
expect(data).toEqual({
success: false,
error: {
code: "INVALID_JSON",
message: "Invalid JSON body",
},
})
expect(mockDeleteImage).not.toHaveBeenCalled()
})

it("returns 400 when r2Key is missing", async () => {
mockAuth.mockResolvedValue({ userId: "user_123" })

const response = await POST(buildRequest(JSON.stringify({})))
const data = await response.json()

expect(response.status).toBe(400)
expect(data).toEqual({
success: false,
error: {
code: "MISSING_KEY",
message: "Missing r2Key",
},
})
})

it("returns 403 when user does not own the object key", async () => {
mockAuth.mockResolvedValue({ userId: "user_123" })

const otherUserHash = crypto.createHash("sha256").update("different_user").digest("hex")
const response = await POST(buildRequest(JSON.stringify({ r2Key: `generated/${otherUserHash}/image.png` })))
const data = await response.json()

expect(response.status).toBe(403)
expect(data).toEqual({
success: false,
error: {
code: "FORBIDDEN",
message: "Not authorized to delete this image",
},
})
expect(mockDeleteImage).not.toHaveBeenCalled()
})

it("deletes object when authenticated user owns the key", async () => {
const userId = "user_123"
mockAuth.mockResolvedValue({ userId })

const userHash = crypto.createHash("sha256").update(userId).digest("hex")
const r2Key = `generated/${userHash}/image.png`

const response = await POST(buildRequest(JSON.stringify({ r2Key })))
const data = await response.json()

expect(response.status).toBe(200)
expect(data).toEqual({ success: true })
expect(mockDeleteImage).toHaveBeenCalledWith(r2Key)
})
})
20 changes: 18 additions & 2 deletions app/api/images/delete/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { auth } from "@clerk/nextjs/server"
import { NextRequest, NextResponse } from "next/server"
import { deleteImage } from "@/lib/storage"
import crypto from "crypto"
import { z } from "zod"

interface DeleteResponse {
success: true
Expand All @@ -22,6 +23,10 @@ interface DeleteError {
}
}

const deleteRequestSchema = z.object({
r2Key: z.string().min(1),
})

/**
* Hash a userId the same way it's hashed when generating R2 keys.
* This ensures authorization checks match the stored key format.
Expand All @@ -42,15 +47,26 @@ export async function POST(
)
}

const { r2Key } = await request.json()
let requestBody: unknown
try {
requestBody = await request.json()
} catch {
return NextResponse.json(
{ success: false, error: { code: "INVALID_JSON", message: "Invalid JSON body" } },
{ status: 400 }
)
}

if (!r2Key || typeof r2Key !== "string") {
const parsed = deleteRequestSchema.safeParse(requestBody)
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: { code: "MISSING_KEY", message: "Missing r2Key" } },
{ status: 400 }
)
}

const { r2Key } = parsed.data

/**
* Security check: Ensure the key contains the user's hashed ID to prevent
* deleting other users' images.
Expand Down
Loading
Loading